openspec: scan-alert-hardening change — different-finder re-alert + fingerprint 24h block
This commit is contained in:
2
openspec/changes/scan-alert-hardening/.openspec.yaml
Normal file
2
openspec/changes/scan-alert-hardening/.openspec.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-08-07
|
||||||
3
openspec/changes/scan-alert-hardening/README.md
Normal file
3
openspec/changes/scan-alert-hardening/README.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# scan-alert-hardening
|
||||||
|
|
||||||
|
Alert hardening: re-alert on different finder phone within 250m window + browser fingerprint 24h block (anti-spam)
|
||||||
45
openspec/changes/scan-alert-hardening/design.md
Normal file
45
openspec/changes/scan-alert-hardening/design.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Phase 2's alert throttle (250 m / 10-min window) blocks repeat alerts from the same spot but doesn't distinguish *finders* — multiple people at the same spot only get one alert, and one device can re-alert by moving around. This change adds (A) a different-finder re-alert rule and (B) a 24 h per-device block via browser fingerprinting. Anti-spam motivation mirrors the 2014 system (which tracked fingerprint + IP on every alert).
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Every distinct finder who leaves a phone number reaches the owner (within the window).
|
||||||
|
- One device cannot spam the owner more than once per 24 h.
|
||||||
|
- Minimal dependency footprint: hand-rolled fingerprint hash, no third-party JS.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- Identity-grade fingerprinting (this is anti-spam, not authentication; a determined attacker can fake signals).
|
||||||
|
- IP-based blocking (privacy + NAT false positives) — fingerprint only.
|
||||||
|
- Rate limits tied to plans/billing (Phase 5).
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
1. **Fingerprint = lightweight hand-rolled hash** of `navigator.userAgent`, `language`, `timezoneOffset`, `screen WxH`, `platform`, `hardwareConcurrency`. JS computes a 32-bit hash → base36 string. *Alternative:* fingerprintjs-lite (heavier, more robust) — deferred; the hand-rolled version is dependency-free and adequate for anti-spam; upgradeable later.
|
||||||
|
|
||||||
|
2. **Fingerprint block is checked FIRST** in `shouldAlert` (before the 10-min throttle): a fingerprint seen in the last 24 h ⇒ record-only. *Rationale:* the same device is the same person regardless of which location/phone they present.
|
||||||
|
|
||||||
|
3. **Different-finder rule lives inside the 10-min window branch**: within the window, re-alert if `moved > 250 m` OR (`newPhone != ""` AND `newPhone != lastAlert.scanner_phone`). Outside the window, alert as before.
|
||||||
|
|
||||||
|
4. **Contact path dedup**: `FinderContact` queries the last alerted scan; within the window and same phone ⇒ store-only (no SMS). Different phone ⇒ SMS. Fingerprint present and seen within 24 h ⇒ store-only.
|
||||||
|
|
||||||
|
5. **Schema**: `ALTER TABLE scans ADD COLUMN IF NOT EXISTS fingerprint TEXT;` (idempotent, nullable). New sqlc query `GetRecentScanByFingerprint` (`WHERE fingerprint = $1 AND scanned_at > now() - interval '24 hours' ORDER BY scanned_at DESC LIMIT 1`).
|
||||||
|
|
||||||
|
6. **ScanRequest gains optional `phone` + `fingerprint`**; both flow into `InsertScan`.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [Fingerprint is spoofable] → accepted (anti-spam, not security); document as such.
|
||||||
|
- [Different-phone rule could alert twice from one person with two numbers] → acceptable; the owner sees both contacts and the 24 h fingerprint block caps same-device repeats.
|
||||||
|
- [Fingerprint column grows] → TEXT, nullable, negligible.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. `make db-up` (idempotent ALTER) → `scans.fingerprint`.
|
||||||
|
2. sqlc `make generate` (new query + column in model).
|
||||||
|
3. Deploy code + JS; no data migration needed.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- None blocking. (24 h window is a constant; adjustable later.)
|
||||||
28
openspec/changes/scan-alert-hardening/proposal.md
Normal file
28
openspec/changes/scan-alert-hardening/proposal.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
The Phase 2 alert throttle prevents SMS spam from the *same spot*, but two gaps remain: (1) **multiple different finders at the same location** — each deserves to reach the owner, yet the current rule re-alerts only on movement >250 m; (2) **the same device can spam alerts** across different locations within minutes — there is no per-device throttle. This change hardens the alert path: re-alert on a *different finder phone* within the window, and block repeat alerts from the *same browser fingerprint* within 24 hours.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **Different-finder re-alert** (`sms-alerting`): within the 10-minute window, an alert is also sent when the new scan carries a finder phone **different** from the last alerted scan's phone (multiple finders at the same spot each reach the owner). Same phone → no re-alert (unchanged dedup).
|
||||||
|
- **Fingerprint 24 h block** (`fingerprint-throttle`): the tag page's JS computes a lightweight browser fingerprint and sends it with every scan. If the same fingerprint was seen within the last 24 hours, the scan is recorded but **no alert is sent**. A fresh device alerts normally.
|
||||||
|
- **Schema**: `scans` gains a `fingerprint` column (idempotent ALTER); scan endpoint accepts an optional `fingerprint` + `phone`; contact flow dedups by phone within the window.
|
||||||
|
- The finder-contact form now also carries the fingerprint (same anti-spam coverage on the contact path).
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `fingerprint-throttle`: JS fingerprint generation, per-device 24 h alert block, fingerprint stored on scans.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `sms-alerting`: ADDED — different-finder re-alert within the window; contact-path dedup.
|
||||||
|
- `database`: ADDED — `scans.fingerprint` column.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **New/changed Go**: `internal/handlers/scan.go` (ScanRequest gains `phone`+`fingerprint`; `shouldAlert` gains the different-phone rule and the fingerprint check; `FinderContact` dedups + carries fingerprint), `internal/db/queries.sql` (new `GetRecentScanByFingerprint` query), regenerated sqlc.
|
||||||
|
- **Changed JS**: `templates/tag-public.html` (fingerprint computation sent with scan + contact).
|
||||||
|
- **Schema**: `scans.fingerprint` idempotent ALTER.
|
||||||
|
- No changes to auth, registry, or templates beyond the script.
|
||||||
12
openspec/changes/scan-alert-hardening/specs/database/spec.md
Normal file
12
openspec/changes/scan-alert-hardening/specs/database/spec.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: scans.fingerprint column
|
||||||
|
The `scans` table SHALL include a nullable `fingerprint` text column, added idempotently so existing databases migrate cleanly.
|
||||||
|
|
||||||
|
#### Scenario: Fresh database
|
||||||
|
- **WHEN** `db/schema.sql` is applied to an empty database
|
||||||
|
- **THEN** `scans.fingerprint` exists and is nullable
|
||||||
|
|
||||||
|
#### Scenario: Existing database
|
||||||
|
- **WHEN** `db/schema.sql` is re-applied to a database created before this column
|
||||||
|
- **THEN** the column is added without error, nullable
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Fingerprint on scans
|
||||||
|
The tag page SHALL compute a lightweight browser fingerprint (UA, language, timezone, screen, platform hash) and include it with every scan POST and finder-contact POST. The scan SHALL store it in `scans.fingerprint`.
|
||||||
|
|
||||||
|
#### Scenario: Scan carries fingerprint
|
||||||
|
- **WHEN** the tag page scripts a scan POST
|
||||||
|
- **THEN** the request includes a fingerprint and the scan row stores it
|
||||||
|
|
||||||
|
### Requirement: 24-hour per-device alert block
|
||||||
|
When a scan arrives with a fingerprint that was seen on an earlier scan within the last 24 hours, the system SHALL record the scan but SHALL NOT send an alert.
|
||||||
|
|
||||||
|
#### Scenario: Same device within 24 h
|
||||||
|
- **WHEN** a scan arrives with a fingerprint matching a scan from under 24 h ago
|
||||||
|
- **THEN** the scan is recorded with `alert_sent = false` and no SMS is sent
|
||||||
|
|
||||||
|
#### Scenario: Fresh device
|
||||||
|
- **WHEN** a scan arrives with a fingerprint not seen in the last 24 h
|
||||||
|
- **THEN** normal alert rules apply (an alert is sent if the throttle conditions permit)
|
||||||
|
|
||||||
|
### Requirement: Contact path coverage
|
||||||
|
The finder-contact submission SHALL also be subject to the fingerprint block when a fingerprint is provided.
|
||||||
|
|
||||||
|
#### Scenario: Contact from a blocked device
|
||||||
|
- **WHEN** a contact submission carries a fingerprint seen within 24 h
|
||||||
|
- **THEN** the number is stored but no owner SMS is sent
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Different-finder re-alert
|
||||||
|
Within the 10-minute alert window, an alert SHALL also be sent when the new scan carries a finder phone that differs from the phone on the last alerted scan, even if the location moved less than 250 m.
|
||||||
|
|
||||||
|
#### Scenario: Second finder at same spot
|
||||||
|
- **WHEN** a scan within the window arrives with a phone different from the last alerted scan's phone
|
||||||
|
- **THEN** a new alert is sent to the owner
|
||||||
|
|
||||||
|
#### Scenario: Same finder re-submits
|
||||||
|
- **WHEN** a scan within the window arrives with the same phone as the last alerted scan
|
||||||
|
- **THEN** no new alert is sent
|
||||||
|
|
||||||
|
### Requirement: Contact-path phone dedup
|
||||||
|
The finder-contact submission SHALL not send an owner SMS if the submitted phone matches the last alerted scan's phone within the window (the number is still stored).
|
||||||
|
|
||||||
|
#### Scenario: Same number twice in window
|
||||||
|
- **WHEN** a finder submits the same number again within the window
|
||||||
|
- **THEN** the number is stored but no duplicate SMS is sent
|
||||||
19
openspec/changes/scan-alert-hardening/tasks.md
Normal file
19
openspec/changes/scan-alert-hardening/tasks.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
## 1. Schema & Queries
|
||||||
|
|
||||||
|
- [ ] 1.1 `db/schema.sql`: idempotent `ALTER TABLE scans ADD COLUMN IF NOT EXISTS fingerprint TEXT;`; `make db-up`
|
||||||
|
- [ ] 1.2 `queries.sql`: add `GetRecentScanByFingerprint` (:one, fingerprint + 24 h window); `make generate`; build
|
||||||
|
|
||||||
|
## 2. Fingerprint JS
|
||||||
|
|
||||||
|
- [ ] 2.1 `tag-public.html`: compute fingerprint (UA/language/timezone/screen/platform hash), send with scan POST and contact form (hidden input)
|
||||||
|
|
||||||
|
## 3. Alert Logic
|
||||||
|
|
||||||
|
- [ ] 3.1 `scan.go`: `ScanRequest` gains `phone` + `fingerprint`; `shouldAlert` checks fingerprint block first, then different-phone re-alert within window; pass fingerprint/phone into `InsertScan`
|
||||||
|
- [ ] 3.2 `FinderContact`: accept + store fingerprint; dedup by phone within window; skip SMS when same phone or fingerprint-blocked
|
||||||
|
|
||||||
|
## 4. Verification
|
||||||
|
|
||||||
|
- [ ] 4.1 Unit-style checks via HTTP suite: same fingerprint within 24 h → no alert; fresh fingerprint → alert; different phone at same spot within window → alert; same phone re-submit → no alert
|
||||||
|
- [ ] 4.2 Existing suites still pass (Phase 1 + Phase 2) on fresh DB
|
||||||
|
- [ ] 4.3 `openspec validate scan-alert-hardening`; commit
|
||||||
Reference in New Issue
Block a user