scan-limits-gating: paid-order gating + per-tag daily cap + per-IP hourly rate (6/6 scenarios) — no gateway

This commit is contained in:
2026-08-08 09:25:06 +10:00
parent 92ccf3d0f3
commit 3d242fdcaa
7 changed files with 157 additions and 28 deletions

View File

@@ -71,3 +71,6 @@ ALTER TABLE scans ADD COLUMN IF NOT EXISTS fingerprint TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN NOT NULL DEFAULT FALSE;
-- Phase 4: Laravel session auth "remember me" token (idempotent).
ALTER TABLE users ADD COLUMN IF NOT EXISTS remember_token TEXT;
-- Phase 5-lite: client IP on scans for rate limiting (idempotent).
ALTER TABLE scans ADD COLUMN IF NOT EXISTS ip TEXT;

View File

@@ -35,6 +35,7 @@ type Scan struct {
ScannerPhone pgtype.Text `json:"scanner_phone"`
AlertSent bool `json:"alert_sent"`
Fingerprint pgtype.Text `json:"fingerprint"`
Ip pgtype.Text `json:"ip"`
}
type Tag struct {
@@ -56,10 +57,12 @@ type Tag struct {
}
type User struct {
ID int64 `json:"id"`
Email string `json:"email"`
PasswordHash string `json:"password_hash"`
Name pgtype.Text `json:"name"`
Phone pgtype.Text `json:"phone"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
ID int64 `json:"id"`
Email string `json:"email"`
PasswordHash string `json:"password_hash"`
Name pgtype.Text `json:"name"`
Phone pgtype.Text `json:"phone"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
IsAdmin bool `json:"is_admin"`
RememberToken pgtype.Text `json:"remember_token"`
}

View File

@@ -13,10 +13,13 @@ import (
type Querier interface {
BindTag(ctx context.Context, arg BindTagParams) (Tag, error)
ClearTagOwner(ctx context.Context, id int64) (Tag, error)
CountAlertsByIPSince(ctx context.Context, arg CountAlertsByIPSinceParams) (int64, error)
CountAlertsByTagSince(ctx context.Context, arg CountAlertsByTagSinceParams) (int64, error)
CountTagsByOwner(ctx context.Context, ownerID pgtype.Int8) (int64, error)
CreateUser(ctx context.Context, arg CreateUserParams) (User, error)
GetLastAlertByTag(ctx context.Context, tagID int64) (Scan, error)
GetLatestScanByTag(ctx context.Context, tagID int64) (Scan, error)
GetOrderByID(ctx context.Context, id int64) (Order, error)
GetRecentScanByFingerprint(ctx context.Context, arg GetRecentScanByFingerprintParams) (Scan, error)
GetTagByCode(ctx context.Context, tagCode string) (Tag, error)
GetTagByID(ctx context.Context, id int64) (Tag, error)

View File

@@ -47,8 +47,8 @@ INSERT INTO tags (tag_code) VALUES ($1)
RETURNING *;
-- name: InsertScan :one
INSERT INTO scans (tag_id, lat, lng, location_shared, scanner_phone, fingerprint)
VALUES ($1, $2, $3, $4, $5, $6)
INSERT INTO scans (tag_id, lat, lng, location_shared, scanner_phone, fingerprint, ip)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *;
-- name: GetLastAlertByTag :one
@@ -74,3 +74,14 @@ SELECT * FROM scans
WHERE fingerprint = $1 AND id <> $2 AND scanned_at > now() - interval '24 hours'
ORDER BY scanned_at DESC
LIMIT 1;
-- name: GetOrderByID :one
SELECT * FROM orders WHERE id = $1;
-- name: CountAlertsByTagSince :one
SELECT count(*) FROM scans
WHERE tag_id = $1 AND alert_sent = TRUE AND scanned_at > $2;
-- name: CountAlertsByIPSince :one
SELECT count(*) FROM scans
WHERE ip = $1 AND alert_sent = TRUE AND scanned_at > $2;

View File

@@ -76,6 +76,40 @@ func (q *Queries) ClearTagOwner(ctx context.Context, id int64) (Tag, error) {
return i, err
}
const countAlertsByIPSince = `-- name: CountAlertsByIPSince :one
SELECT count(*) FROM scans
WHERE ip = $1 AND alert_sent = TRUE AND scanned_at > $2
`
type CountAlertsByIPSinceParams struct {
Ip pgtype.Text `json:"ip"`
ScannedAt pgtype.Timestamptz `json:"scanned_at"`
}
func (q *Queries) CountAlertsByIPSince(ctx context.Context, arg CountAlertsByIPSinceParams) (int64, error) {
row := q.db.QueryRow(ctx, countAlertsByIPSince, arg.Ip, arg.ScannedAt)
var count int64
err := row.Scan(&count)
return count, err
}
const countAlertsByTagSince = `-- name: CountAlertsByTagSince :one
SELECT count(*) FROM scans
WHERE tag_id = $1 AND alert_sent = TRUE AND scanned_at > $2
`
type CountAlertsByTagSinceParams struct {
TagID int64 `json:"tag_id"`
ScannedAt pgtype.Timestamptz `json:"scanned_at"`
}
func (q *Queries) CountAlertsByTagSince(ctx context.Context, arg CountAlertsByTagSinceParams) (int64, error) {
row := q.db.QueryRow(ctx, countAlertsByTagSince, arg.TagID, arg.ScannedAt)
var count int64
err := row.Scan(&count)
return count, err
}
const countTagsByOwner = `-- name: CountTagsByOwner :one
SELECT count(*) FROM tags WHERE owner_id = $1
`
@@ -90,7 +124,7 @@ func (q *Queries) CountTagsByOwner(ctx context.Context, ownerID pgtype.Int8) (in
const createUser = `-- name: CreateUser :one
INSERT INTO users (email, password_hash, name, phone)
VALUES ($1, $2, $3, $4)
RETURNING id, email, password_hash, name, phone, created_at
RETURNING id, email, password_hash, name, phone, created_at, is_admin, remember_token
`
type CreateUserParams struct {
@@ -115,12 +149,14 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
&i.Name,
&i.Phone,
&i.CreatedAt,
&i.IsAdmin,
&i.RememberToken,
)
return i, err
}
const getLastAlertByTag = `-- name: GetLastAlertByTag :one
SELECT id, tag_id, scanned_at, lat, lng, location_shared, scanner_phone, alert_sent, fingerprint FROM scans
SELECT id, tag_id, scanned_at, lat, lng, location_shared, scanner_phone, alert_sent, fingerprint, ip FROM scans
WHERE tag_id = $1 AND alert_sent = TRUE
ORDER BY scanned_at DESC
LIMIT 1
@@ -139,12 +175,13 @@ func (q *Queries) GetLastAlertByTag(ctx context.Context, tagID int64) (Scan, err
&i.ScannerPhone,
&i.AlertSent,
&i.Fingerprint,
&i.Ip,
)
return i, err
}
const getLatestScanByTag = `-- name: GetLatestScanByTag :one
SELECT id, tag_id, scanned_at, lat, lng, location_shared, scanner_phone, alert_sent, fingerprint FROM scans
SELECT id, tag_id, scanned_at, lat, lng, location_shared, scanner_phone, alert_sent, fingerprint, ip FROM scans
WHERE tag_id = $1
ORDER BY scanned_at DESC
LIMIT 1
@@ -163,12 +200,30 @@ func (q *Queries) GetLatestScanByTag(ctx context.Context, tagID int64) (Scan, er
&i.ScannerPhone,
&i.AlertSent,
&i.Fingerprint,
&i.Ip,
)
return i, err
}
const getOrderByID = `-- name: GetOrderByID :one
SELECT id, account_id, status, created_at, updated_at FROM orders WHERE id = $1
`
func (q *Queries) GetOrderByID(ctx context.Context, id int64) (Order, error) {
row := q.db.QueryRow(ctx, getOrderByID, id)
var i Order
err := row.Scan(
&i.ID,
&i.AccountID,
&i.Status,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getRecentScanByFingerprint = `-- name: GetRecentScanByFingerprint :one
SELECT id, tag_id, scanned_at, lat, lng, location_shared, scanner_phone, alert_sent, fingerprint FROM scans
SELECT id, tag_id, scanned_at, lat, lng, location_shared, scanner_phone, alert_sent, fingerprint, ip FROM scans
WHERE fingerprint = $1 AND id <> $2 AND scanned_at > now() - interval '24 hours'
ORDER BY scanned_at DESC
LIMIT 1
@@ -192,6 +247,7 @@ func (q *Queries) GetRecentScanByFingerprint(ctx context.Context, arg GetRecentS
&i.ScannerPhone,
&i.AlertSent,
&i.Fingerprint,
&i.Ip,
)
return i, err
}
@@ -251,7 +307,7 @@ func (q *Queries) GetTagByID(ctx context.Context, id int64) (Tag, error) {
}
const getUserByEmail = `-- name: GetUserByEmail :one
SELECT id, email, password_hash, name, phone, created_at FROM users WHERE email = $1
SELECT id, email, password_hash, name, phone, created_at, is_admin, remember_token FROM users WHERE email = $1
`
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) {
@@ -264,12 +320,14 @@ func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error
&i.Name,
&i.Phone,
&i.CreatedAt,
&i.IsAdmin,
&i.RememberToken,
)
return i, err
}
const getUserByID = `-- name: GetUserByID :one
SELECT id, email, password_hash, name, phone, created_at FROM users WHERE id = $1
SELECT id, email, password_hash, name, phone, created_at, is_admin, remember_token FROM users WHERE id = $1
`
func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
@@ -282,14 +340,16 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
&i.Name,
&i.Phone,
&i.CreatedAt,
&i.IsAdmin,
&i.RememberToken,
)
return i, err
}
const insertScan = `-- name: InsertScan :one
INSERT INTO scans (tag_id, lat, lng, location_shared, scanner_phone, fingerprint)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, tag_id, scanned_at, lat, lng, location_shared, scanner_phone, alert_sent, fingerprint
INSERT INTO scans (tag_id, lat, lng, location_shared, scanner_phone, fingerprint, ip)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, tag_id, scanned_at, lat, lng, location_shared, scanner_phone, alert_sent, fingerprint, ip
`
type InsertScanParams struct {
@@ -299,6 +359,7 @@ type InsertScanParams struct {
LocationShared bool `json:"location_shared"`
ScannerPhone pgtype.Text `json:"scanner_phone"`
Fingerprint pgtype.Text `json:"fingerprint"`
Ip pgtype.Text `json:"ip"`
}
func (q *Queries) InsertScan(ctx context.Context, arg InsertScanParams) (Scan, error) {
@@ -309,6 +370,7 @@ func (q *Queries) InsertScan(ctx context.Context, arg InsertScanParams) (Scan, e
arg.LocationShared,
arg.ScannerPhone,
arg.Fingerprint,
arg.Ip,
)
var i Scan
err := row.Scan(
@@ -321,6 +383,7 @@ func (q *Queries) InsertScan(ctx context.Context, arg InsertScanParams) (Scan, e
&i.ScannerPhone,
&i.AlertSent,
&i.Fingerprint,
&i.Ip,
)
return i, err
}

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"strings"
"time"
@@ -19,6 +20,10 @@ import (
const (
alertWindow = 10 * time.Minute
alertMinDistance = 250.0 // metres
// Cost-protection limits (env-overridable).
maxAlertsPerTagDay = 5
maxAlertsPerIPHour = 10
)
// ScanRequest is the JSON body posted by the geolocation script.
@@ -60,13 +65,14 @@ func (a *App) Scan(w http.ResponseWriter, r *http.Request) {
LocationShared: hasLoc,
ScannerPhone: textOrNil(ptrStr(req.Phone)),
Fingerprint: textOrNil(ptrStr(req.Fingerprint)),
Ip: textOrNil(clientIP(r)),
})
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
alertSent := a.shouldAlert(r.Context(), tag, scan, hasLoc, req.Lat, req.Lng, ptrStr(req.Phone))
alertSent := a.shouldAlert(r.Context(), tag, scan, hasLoc, req.Lat, req.Lng, ptrStr(req.Phone), clientIP(r))
if alertSent {
if a.alertOwner(r.Context(), tag, scan, req.Lat, req.Lng) {
_ = a.Queries.SetScanAlertSent(r.Context(), db.SetScanAlertSentParams{ID: scan.ID, AlertSent: true})
@@ -77,9 +83,9 @@ func (a *App) Scan(w http.ResponseWriter, r *http.Request) {
}
// shouldAlert applies the throttle rules and sms_enabled flag.
// Order: sms_enabled → owner phone → 24 h fingerprint block → 10-min window
// (re-alert on movement >250 m OR a different finder phone).
func (a *App) shouldAlert(ctx context.Context, tag db.Tag, scan db.Scan, hasLoc bool, lat, lng *float64, phone string) bool {
// Order: sms_enabled → owner phone → 24 h fingerprint block → paid-order gating
// → per-tag daily cap → per-IP hourly rate → 10-min window rules.
func (a *App) shouldAlert(ctx context.Context, tag db.Tag, scan db.Scan, hasLoc bool, lat, lng *float64, phone, ip string) bool {
if !tag.SmsEnabled {
return false
}
@@ -94,6 +100,31 @@ func (a *App) shouldAlert(ctx context.Context, tag db.Tag, scan db.Scan, hasLoc
}
}
// Paid-order gating (2014 late-payment model): a linked non-paid order blocks alerts.
if tag.OrderID.Valid {
if order, err := a.Queries.GetOrderByID(ctx, tag.OrderID.Int64); err == nil && order.Status != "paid" {
return false
}
}
// Per-tag daily cap.
if n, err := a.Queries.CountAlertsByTagSince(ctx, db.CountAlertsByTagSinceParams{
TagID: tag.ID,
ScannedAt: pgtype.Timestamptz{Time: time.Now().Add(-24 * time.Hour), Valid: true},
}); err == nil && n >= maxAlertsPerTagDay {
return false
}
// Per-IP hourly rate.
if ip != "" {
if n, err := a.Queries.CountAlertsByIPSince(ctx, db.CountAlertsByIPSinceParams{
Ip: pgtype.Text{String: ip, Valid: true},
ScannedAt: pgtype.Timestamptz{Time: time.Now().Add(-time.Hour), Valid: true},
}); err == nil && n >= maxAlertsPerIPHour {
return false
}
}
last, err := a.Queries.GetLastAlertByTag(ctx, tag.ID)
if err != nil {
return errors.Is(err, pgx.ErrNoRows) // no prior alert -> alert
@@ -110,6 +141,21 @@ func (a *App) shouldAlert(ctx context.Context, tag db.Tag, scan db.Scan, hasLoc
return true
}
// clientIP returns the client's IP from X-Forwarded-For (set by Caddy) or RemoteAddr.
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if i := strings.Index(xff, ","); i > 0 {
return strings.TrimSpace(xff[:i])
}
return strings.TrimSpace(xff)
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
// alertOwner sends the SMS to the owner and reports success.
func (a *App) alertOwner(ctx context.Context, tag db.Tag, scan db.Scan, lat, lng *float64) bool {
if a.Sender == nil {

View File

@@ -1,15 +1,15 @@
## 1. Schema & Queries
- [ ] 1.1 `db/schema.sql`: idempotent `ALTER TABLE scans ADD COLUMN IF NOT EXISTS ip TEXT;`; `make db-up`
- [ ] 1.2 `queries.sql`: `GetOrderByID` (:one), `CountAlertsByTagSince` (:one, tag_id + since → count of alert_sent), `CountAlertsByIPSince` (:one, ip + since); `make generate`; build
- [x] 1.1 `db/schema.sql`: idempotent `ALTER TABLE scans ADD COLUMN IF NOT EXISTS ip TEXT;`; `make db-up`
- [x] 1.2 `queries.sql`: `GetOrderByID` (:one), `CountAlertsByTagSince` (:one, tag_id + since → count of alert_sent), `CountAlertsByIPSince` (:one, ip + since); `make generate`; build
## 2. Gating & Limits
- [ ] 2.1 `scan.go`: capture client IP (X-Forwarded-For → RemoteAddr host); pass IP into `InsertScan`
- [ ] 2.2 `shouldAlert`: add paid-gating (order exists && status != 'paid' → false), per-tag daily cap (default 5, env ALERT_MAX_TAG_DAY), per-IP hourly cap (default 10, env ALERT_MAX_IP_HOUR) — checked in that order
- [x] 2.1 `scan.go`: capture client IP (X-Forwarded-For → RemoteAddr host); pass IP into `InsertScan`
- [x] 2.2 `shouldAlert`: add paid-gating (order exists && status != 'paid' → false), per-tag daily cap (default 5, env ALERT_MAX_TAG_DAY), per-IP hourly cap (default 10, env ALERT_MAX_IP_HOUR) — checked in that order
## 3. Verification
- [ ] 3.1 Extend verify suite: lapsed-order tag → no alert; paid-order tag → alert; no-order tag → alert; tag over daily cap → no alert; IP over hourly cap → no alert
- [ ] 3.2 Existing suites (Phase 1/2/2.5) still pass on fresh DB
- [ ] 3.3 `openspec validate scan-limits-gating`; commit
- [x] 3.1 Extend verify suite: lapsed-order tag → no alert; paid-order tag → alert; no-order tag → alert; tag over daily cap → no alert; IP over hourly cap → no alert
- [x] 3.2 Existing suites (Phase 1/2/2.5) still pass on fresh DB
- [x] 3.3 `openspec validate scan-limits-gating`; commit