From 92ccf3d0f3adf78afe1a537c2e724bde504937c7 Mon Sep 17 00:00:00 2001 From: Sam Rolfe Date: Sat, 8 Aug 2026 09:22:09 +1000 Subject: [PATCH] =?UTF-8?q?openspec:=20scan-limits-gating=20change=20?= =?UTF-8?q?=E2=80=94=20paid-order=20gating=20+=20daily/IP=20alert=20caps?= =?UTF-8?q?=20(no=20gateway)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../changes/scan-limits-gating/.openspec.yaml | 2 + openspec/changes/scan-limits-gating/README.md | 3 ++ openspec/changes/scan-limits-gating/design.md | 40 +++++++++++++++++++ .../changes/scan-limits-gating/proposal.md | 27 +++++++++++++ .../specs/alert-limits/spec.md | 30 ++++++++++++++ .../specs/sms-alerting/spec.md | 15 +++++++ openspec/changes/scan-limits-gating/tasks.md | 15 +++++++ 7 files changed, 132 insertions(+) create mode 100644 openspec/changes/scan-limits-gating/.openspec.yaml create mode 100644 openspec/changes/scan-limits-gating/README.md create mode 100644 openspec/changes/scan-limits-gating/design.md create mode 100644 openspec/changes/scan-limits-gating/proposal.md create mode 100644 openspec/changes/scan-limits-gating/specs/alert-limits/spec.md create mode 100644 openspec/changes/scan-limits-gating/specs/sms-alerting/spec.md create mode 100644 openspec/changes/scan-limits-gating/tasks.md diff --git a/openspec/changes/scan-limits-gating/.openspec.yaml b/openspec/changes/scan-limits-gating/.openspec.yaml new file mode 100644 index 0000000..878dc31 --- /dev/null +++ b/openspec/changes/scan-limits-gating/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-07 diff --git a/openspec/changes/scan-limits-gating/README.md b/openspec/changes/scan-limits-gating/README.md new file mode 100644 index 0000000..14f12cd --- /dev/null +++ b/openspec/changes/scan-limits-gating/README.md @@ -0,0 +1,3 @@ +# scan-limits-gating + +Alert gating: paid-order requirement (2014 late-payment model) + per-tag daily alert cap + per-IP rate limit — no payment gateway diff --git a/openspec/changes/scan-limits-gating/design.md b/openspec/changes/scan-limits-gating/design.md new file mode 100644 index 0000000..001d9b9 --- /dev/null +++ b/openspec/changes/scan-limits-gating/design.md @@ -0,0 +1,40 @@ +## 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-gating** → **daily cap** → **IP 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`, `CountAlertsByIPSince` → `make generate`. +3. Handlers: scan.go gating + IP capture; `InsertScan` gains IP. +4. Verify with extended suite; commit. + +## Open Questions + +- None blocking. diff --git a/openspec/changes/scan-limits-gating/proposal.md b/openspec/changes/scan-limits-gating/proposal.md new file mode 100644 index 0000000..0130d4f --- /dev/null +++ b/openspec/changes/scan-limits-gating/proposal.md @@ -0,0 +1,27 @@ +## Why + +The product needs cost protection and the 2014 paid-gating model, but a payment gateway (Stripe) is explicitly deferred. This change delivers the **mechanism** without the gateway: alerts only fire for tags whose linked order is `paid` (the 2014 `is_paid`/`late_payment` suspension), plus hard **per-tag daily alert caps** and a **per-IP rate limit** — all pure app logic on the existing schema. Stripe/Paddle plugs in later (Phase 5 full) by just setting `orders.status`. + +## What Changes + +- **Paid-gating** (`sms-alerting`): if a tag has a linked order (`order_id` set) and that order's status is not `paid`, scans are recorded but **no SMS is sent** (owner sees "payments suspended" behaviour). No order yet → alerts allowed (transitional until sales flow exists). +- **Per-tag daily alert cap**: a tag can alert at most N times per 24 h (configurable constant, e.g. 5) — protects SMS cost against heavy repeated scanning. +- **Per-IP rate limit**: a scanner IP can trigger at most M alerts per hour (configurable) — guards against automation (works alongside the existing fingerprint block). +- **Admin visibility**: TagResource already links orders; the order status drives the gating — no new admin UI needed. + +## Capabilities + +### New Capabilities + +- `alert-limits`: per-tag daily cap + per-IP hourly rate limit. + +### Modified Capabilities + +- `sms-alerting`: ADDED — paid-order gating (no order = allowed, transitional; non-paid order = blocked). +- `database`: no schema change required (orders.status + tag.order_id already exist). + +## Impact + +- **Go**: `internal/handlers/scan.go` (`shouldAlert` gains paid-gating + cap checks), `internal/db/queries.sql` (2 new count queries), regenerated sqlc. +- **Constants**: `maxAlertsPerTagDay`, `maxAlertsPerIPHour` (env-overridable via `ALERT_*` vars). +- No external services; no Stripe; no schema migration. diff --git a/openspec/changes/scan-limits-gating/specs/alert-limits/spec.md b/openspec/changes/scan-limits-gating/specs/alert-limits/spec.md new file mode 100644 index 0000000..5ff6b23 --- /dev/null +++ b/openspec/changes/scan-limits-gating/specs/alert-limits/spec.md @@ -0,0 +1,30 @@ +## ADDED Requirements + +### Requirement: Paid-order gating +When a tag has a linked order (`order_id` not null) whose status is not `paid`, scans SHALL be recorded but no alert SMS SHALL be sent. Tags without a linked order SHALL still alert (transitional). + +#### Scenario: Lapsed order +- **WHEN** a scan arrives on a tag whose order status is `lapsed` or `pending` +- **THEN** the scan is recorded with `alert_sent = false` and no SMS is sent + +#### Scenario: Paid order +- **WHEN** a scan arrives on a tag whose order is `paid` +- **THEN** normal alert rules apply + +#### Scenario: No order yet +- **WHEN** a scan arrives on a tag with no linked order +- **THEN** normal alert rules apply (transitional behaviour) + +### Requirement: Per-tag daily alert cap +A tag SHALL alert at most N times in any 24-hour window (N configurable, default 5). Excess scans are recorded without alerts. + +#### Scenario: Cap reached +- **WHEN** a tag has already alerted N times in the last 24 h +- **THEN** further scans are recorded but no SMS is sent + +### Requirement: Per-IP hourly rate limit +A scanner IP SHALL trigger at most M alerts per hour (M configurable, default 10). Excess alerts from the same IP are suppressed. + +#### Scenario: Rate exceeded +- **WHEN** an IP has triggered M alerts in the last hour +- **THEN** further scan alerts from that IP are suppressed (scans still recorded) diff --git a/openspec/changes/scan-limits-gating/specs/sms-alerting/spec.md b/openspec/changes/scan-limits-gating/specs/sms-alerting/spec.md new file mode 100644 index 0000000..07bbb30 --- /dev/null +++ b/openspec/changes/scan-limits-gating/specs/sms-alerting/spec.md @@ -0,0 +1,15 @@ +## ADDED Requirements + +### Requirement: Paid-order gating in alert decision +The alert decision (`shouldAlert`) SHALL return false when the tag's linked order exists and is not `paid`. + +#### Scenario: Lapsed tag scan +- **WHEN** a scan arrives on a tag with a non-paid order +- **THEN** `shouldAlert` returns false (record only) + +### Requirement: Cap checks in alert decision +The alert decision SHALL respect the per-tag daily cap and per-IP hourly rate limit. + +#### Scenario: Over cap +- **WHEN** the daily or hourly counter is exceeded +- **THEN** `shouldAlert` returns false (record only) diff --git a/openspec/changes/scan-limits-gating/tasks.md b/openspec/changes/scan-limits-gating/tasks.md new file mode 100644 index 0000000..195edce --- /dev/null +++ b/openspec/changes/scan-limits-gating/tasks.md @@ -0,0 +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 + +## 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 + +## 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