scan-alert-hardening: different-finder re-alert within 250m window + fingerprint 24h block (12/12 scenarios pass)
This commit is contained in:
@@ -63,3 +63,6 @@ CREATE TABLE IF NOT EXISTS orders (
|
||||
-- Tag → product / order linkage (populated by the Laravel admin, Phase 4).
|
||||
ALTER TABLE tags ADD COLUMN IF NOT EXISTS product_id BIGINT REFERENCES products(id);
|
||||
ALTER TABLE tags ADD COLUMN IF NOT EXISTS order_id BIGINT REFERENCES orders(id);
|
||||
|
||||
-- Phase 2.5: per-device anti-spam fingerprint on scans (idempotent).
|
||||
ALTER TABLE scans ADD COLUMN IF NOT EXISTS fingerprint TEXT;
|
||||
|
||||
@@ -34,6 +34,7 @@ type Scan struct {
|
||||
LocationShared bool `json:"location_shared"`
|
||||
ScannerPhone pgtype.Text `json:"scanner_phone"`
|
||||
AlertSent bool `json:"alert_sent"`
|
||||
Fingerprint pgtype.Text `json:"fingerprint"`
|
||||
}
|
||||
|
||||
type Tag struct {
|
||||
|
||||
@@ -17,6 +17,7 @@ type Querier interface {
|
||||
CreateUser(ctx context.Context, arg CreateUserParams) (User, error)
|
||||
GetLastAlertByTag(ctx context.Context, tagID int64) (Scan, error)
|
||||
GetLatestScanByTag(ctx context.Context, tagID int64) (Scan, 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)
|
||||
GetUserByEmail(ctx context.Context, email string) (User, error)
|
||||
|
||||
@@ -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)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
INSERT INTO scans (tag_id, lat, lng, location_shared, scanner_phone, fingerprint)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetLastAlertByTag :one
|
||||
@@ -67,4 +67,10 @@ LIMIT 1;
|
||||
UPDATE scans SET alert_sent = $2 WHERE id = $1;
|
||||
|
||||
-- name: SetScanPhone :exec
|
||||
UPDATE scans SET scanner_phone = $2 WHERE id = $1;
|
||||
UPDATE scans SET scanner_phone = $2, fingerprint = $3 WHERE id = $1;
|
||||
|
||||
-- name: GetRecentScanByFingerprint :one
|
||||
SELECT * FROM scans
|
||||
WHERE fingerprint = $1 AND id <> $2 AND scanned_at > now() - interval '24 hours'
|
||||
ORDER BY scanned_at DESC
|
||||
LIMIT 1;
|
||||
|
||||
@@ -120,7 +120,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
|
||||
}
|
||||
|
||||
const getLastAlertByTag = `-- name: GetLastAlertByTag :one
|
||||
SELECT id, tag_id, scanned_at, lat, lng, location_shared, scanner_phone, alert_sent FROM scans
|
||||
SELECT id, tag_id, scanned_at, lat, lng, location_shared, scanner_phone, alert_sent, fingerprint FROM scans
|
||||
WHERE tag_id = $1 AND alert_sent = TRUE
|
||||
ORDER BY scanned_at DESC
|
||||
LIMIT 1
|
||||
@@ -138,12 +138,13 @@ func (q *Queries) GetLastAlertByTag(ctx context.Context, tagID int64) (Scan, err
|
||||
&i.LocationShared,
|
||||
&i.ScannerPhone,
|
||||
&i.AlertSent,
|
||||
&i.Fingerprint,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getLatestScanByTag = `-- name: GetLatestScanByTag :one
|
||||
SELECT id, tag_id, scanned_at, lat, lng, location_shared, scanner_phone, alert_sent FROM scans
|
||||
SELECT id, tag_id, scanned_at, lat, lng, location_shared, scanner_phone, alert_sent, fingerprint FROM scans
|
||||
WHERE tag_id = $1
|
||||
ORDER BY scanned_at DESC
|
||||
LIMIT 1
|
||||
@@ -161,6 +162,36 @@ func (q *Queries) GetLatestScanByTag(ctx context.Context, tagID int64) (Scan, er
|
||||
&i.LocationShared,
|
||||
&i.ScannerPhone,
|
||||
&i.AlertSent,
|
||||
&i.Fingerprint,
|
||||
)
|
||||
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
|
||||
WHERE fingerprint = $1 AND id <> $2 AND scanned_at > now() - interval '24 hours'
|
||||
ORDER BY scanned_at DESC
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
type GetRecentScanByFingerprintParams struct {
|
||||
Fingerprint pgtype.Text `json:"fingerprint"`
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetRecentScanByFingerprint(ctx context.Context, arg GetRecentScanByFingerprintParams) (Scan, error) {
|
||||
row := q.db.QueryRow(ctx, getRecentScanByFingerprint, arg.Fingerprint, arg.ID)
|
||||
var i Scan
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.TagID,
|
||||
&i.ScannedAt,
|
||||
&i.Lat,
|
||||
&i.Lng,
|
||||
&i.LocationShared,
|
||||
&i.ScannerPhone,
|
||||
&i.AlertSent,
|
||||
&i.Fingerprint,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -256,9 +287,9 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
|
||||
}
|
||||
|
||||
const insertScan = `-- name: InsertScan :one
|
||||
INSERT INTO scans (tag_id, lat, lng, location_shared, scanner_phone)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, tag_id, scanned_at, lat, lng, location_shared, scanner_phone, alert_sent
|
||||
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
|
||||
`
|
||||
|
||||
type InsertScanParams struct {
|
||||
@@ -267,6 +298,7 @@ type InsertScanParams struct {
|
||||
Lng pgtype.Float8 `json:"lng"`
|
||||
LocationShared bool `json:"location_shared"`
|
||||
ScannerPhone pgtype.Text `json:"scanner_phone"`
|
||||
Fingerprint pgtype.Text `json:"fingerprint"`
|
||||
}
|
||||
|
||||
func (q *Queries) InsertScan(ctx context.Context, arg InsertScanParams) (Scan, error) {
|
||||
@@ -276,6 +308,7 @@ func (q *Queries) InsertScan(ctx context.Context, arg InsertScanParams) (Scan, e
|
||||
arg.Lng,
|
||||
arg.LocationShared,
|
||||
arg.ScannerPhone,
|
||||
arg.Fingerprint,
|
||||
)
|
||||
var i Scan
|
||||
err := row.Scan(
|
||||
@@ -287,6 +320,7 @@ func (q *Queries) InsertScan(ctx context.Context, arg InsertScanParams) (Scan, e
|
||||
&i.LocationShared,
|
||||
&i.ScannerPhone,
|
||||
&i.AlertSent,
|
||||
&i.Fingerprint,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -374,16 +408,17 @@ func (q *Queries) SetScanAlertSent(ctx context.Context, arg SetScanAlertSentPara
|
||||
}
|
||||
|
||||
const setScanPhone = `-- name: SetScanPhone :exec
|
||||
UPDATE scans SET scanner_phone = $2 WHERE id = $1
|
||||
UPDATE scans SET scanner_phone = $2, fingerprint = $3 WHERE id = $1
|
||||
`
|
||||
|
||||
type SetScanPhoneParams struct {
|
||||
ID int64 `json:"id"`
|
||||
ScannerPhone pgtype.Text `json:"scanner_phone"`
|
||||
Fingerprint pgtype.Text `json:"fingerprint"`
|
||||
}
|
||||
|
||||
func (q *Queries) SetScanPhone(ctx context.Context, arg SetScanPhoneParams) error {
|
||||
_, err := q.db.Exec(ctx, setScanPhone, arg.ID, arg.ScannerPhone)
|
||||
_, err := q.db.Exec(ctx, setScanPhone, arg.ID, arg.ScannerPhone, arg.Fingerprint)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -23,8 +23,10 @@ const (
|
||||
|
||||
// ScanRequest is the JSON body posted by the geolocation script.
|
||||
type ScanRequest struct {
|
||||
Lat *float64 `json:"lat"`
|
||||
Lng *float64 `json:"lng"`
|
||||
Lat *float64 `json:"lat"`
|
||||
Lng *float64 `json:"lng"`
|
||||
Phone *string `json:"phone"`
|
||||
Fingerprint *string `json:"fingerprint"`
|
||||
}
|
||||
|
||||
// Scan records a scan (with optional location) and alerts the owner per the
|
||||
@@ -56,13 +58,15 @@ func (a *App) Scan(w http.ResponseWriter, r *http.Request) {
|
||||
Lat: lat,
|
||||
Lng: lng,
|
||||
LocationShared: hasLoc,
|
||||
ScannerPhone: textOrNil(ptrStr(req.Phone)),
|
||||
Fingerprint: textOrNil(ptrStr(req.Fingerprint)),
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
alertSent := a.shouldAlert(r.Context(), tag, scan, hasLoc, req.Lat, req.Lng)
|
||||
alertSent := a.shouldAlert(r.Context(), tag, scan, hasLoc, req.Lat, req.Lng, ptrStr(req.Phone))
|
||||
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})
|
||||
@@ -73,7 +77,9 @@ func (a *App) Scan(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// shouldAlert applies the throttle rules and sms_enabled flag.
|
||||
func (a *App) shouldAlert(ctx context.Context, tag db.Tag, scan db.Scan, hasLoc bool, lat, lng *float64) bool {
|
||||
// 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 {
|
||||
if !tag.SmsEnabled {
|
||||
return false
|
||||
}
|
||||
@@ -81,6 +87,13 @@ func (a *App) shouldAlert(ctx context.Context, tag db.Tag, scan db.Scan, hasLoc
|
||||
return false
|
||||
}
|
||||
|
||||
// 24 h per-device block: same fingerprint seen recently (excluding this scan) => record only.
|
||||
if fp := scan.Fingerprint.String; fp != "" {
|
||||
if _, err := a.Queries.GetRecentScanByFingerprint(ctx, db.GetRecentScanByFingerprintParams{Fingerprint: pgtype.Text{String: fp, Valid: true}, ID: scan.ID}); err == nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
last, err := a.Queries.GetLastAlertByTag(ctx, tag.ID)
|
||||
if err != nil {
|
||||
return errors.Is(err, pgx.ErrNoRows) // no prior alert -> alert
|
||||
@@ -88,11 +101,11 @@ func (a *App) shouldAlert(ctx context.Context, tag db.Tag, scan db.Scan, hasLoc
|
||||
|
||||
// Within the window?
|
||||
if time.Since(last.ScannedAt.Time) < alertWindow {
|
||||
// Re-alert only if we have a location and it moved >250 m.
|
||||
if !hasLoc || !last.Lat.Valid || !last.Lng.Valid {
|
||||
return false
|
||||
}
|
||||
return sms.HaversineMeters(last.Lat.Float64, last.Lng.Float64, *lat, *lng) > alertMinDistance
|
||||
// Re-alert on movement >250 m, or a different finder phone.
|
||||
moved := hasLoc && last.Lat.Valid && last.Lng.Valid &&
|
||||
sms.HaversineMeters(last.Lat.Float64, last.Lng.Float64, *lat, *lng) > alertMinDistance
|
||||
differentPhone := phone != "" && phone != last.ScannerPhone.String
|
||||
return moved || differentPhone
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -136,6 +149,8 @@ func alertMessage(tag db.Tag, t time.Time, lat, lng *float64) string {
|
||||
}
|
||||
|
||||
// FinderContact stores a finder's number on the latest scan and alerts the owner.
|
||||
// Dedup: same phone within the alert window => store only; fingerprint seen in
|
||||
// the last 24 h => store only.
|
||||
func (a *App) FinderContact(w http.ResponseWriter, r *http.Request) {
|
||||
tag, err := a.Queries.GetTagByCode(r.Context(), r.PathValue("tag_code"))
|
||||
if err != nil {
|
||||
@@ -153,6 +168,7 @@ func (a *App) FinderContact(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, `<p class="mt-4 rounded border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-700">Please enter a valid phone number.</p>`)
|
||||
return
|
||||
}
|
||||
fingerprint := strings.TrimSpace(r.FormValue("fingerprint"))
|
||||
|
||||
latest, err := a.Queries.GetLatestScanByTag(r.Context(), tag.ID)
|
||||
if err != nil {
|
||||
@@ -160,19 +176,40 @@ func (a *App) FinderContact(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// No scan yet: record one carrying the finder's number.
|
||||
_, err = a.Queries.InsertScan(r.Context(), db.InsertScanParams{
|
||||
// No scan yet: record one carrying the finder's number + fingerprint.
|
||||
ins, insErr := a.Queries.InsertScan(r.Context(), db.InsertScanParams{
|
||||
TagID: tag.ID, ScannerPhone: pgtype.Text{String: phone, Valid: true},
|
||||
Fingerprint: textOrNil(fingerprint),
|
||||
})
|
||||
if insErr != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
latest = ins
|
||||
} else {
|
||||
err = a.Queries.SetScanPhone(r.Context(), db.SetScanPhoneParams{ID: latest.ID, ScannerPhone: pgtype.Text{String: phone, Valid: true}})
|
||||
err = a.Queries.SetScanPhone(r.Context(), db.SetScanPhoneParams{ID: latest.ID, ScannerPhone: pgtype.Text{String: phone, Valid: true}, Fingerprint: textOrNil(fingerprint)})
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if tag.SmsEnabled && tag.Phone.Valid && a.Sender != nil {
|
||||
// Should we notify? Skip when the same phone was alerted within the window,
|
||||
// or when this device (fingerprint) has already alerted in the last 24 h.
|
||||
notify := tag.SmsEnabled && tag.Phone.Valid && a.Sender != nil
|
||||
if notify && fingerprint != "" {
|
||||
if _, err := a.Queries.GetRecentScanByFingerprint(r.Context(), db.GetRecentScanByFingerprintParams{Fingerprint: pgtype.Text{String: fingerprint, Valid: true}, ID: latest.ID}); err == nil {
|
||||
notify = false
|
||||
}
|
||||
}
|
||||
if notify {
|
||||
if last, err := a.Queries.GetLastAlertByTag(r.Context(), tag.ID); err == nil &&
|
||||
time.Since(last.ScannedAt.Time) < alertWindow && last.ScannerPhone.String == phone {
|
||||
notify = false
|
||||
}
|
||||
}
|
||||
|
||||
if notify {
|
||||
itemType := "item"
|
||||
if tag.ItemType.Valid {
|
||||
itemType = tag.ItemType.String
|
||||
@@ -181,6 +218,9 @@ func (a *App) FinderContact(w http.ResponseWriter, r *http.Request) {
|
||||
itemType, phone, tag.TagCode)
|
||||
if err := a.Sender.Send(sms.NormalizeAU(tag.Phone.String), msg); err != nil {
|
||||
fmt.Println("contact sms failed:", err)
|
||||
} else {
|
||||
// Mark alerted so dedup (same phone / same device) can see it.
|
||||
_ = a.Queries.SetScanAlertSent(r.Context(), db.SetScanAlertSentParams{ID: latest.ID, AlertSent: true})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,6 +228,13 @@ func (a *App) FinderContact(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, `<p class="mt-4 rounded border border-green-200 bg-green-50 px-4 py-2 text-sm text-green-800">Thanks! The owner has been notified with your number.</p>`)
|
||||
}
|
||||
|
||||
func ptrStr(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
|
||||
<div id="contact-area" class="mt-4">
|
||||
<form hx-post="/t/{{.Data.TagCode}}/contact" hx-target="#contact-area" hx-swap="innerHTML" class="flex gap-2">
|
||||
<input type="hidden" name="fingerprint" value="">
|
||||
<input name="phone" type="tel" placeholder="Leave your number for the owner (optional)" class="flex-1 rounded border border-stone-300 px-3 py-2 text-sm" pattern="[0-9+ ]{8,16}">
|
||||
<button type="submit" class="rounded bg-stone-900 px-4 py-2 text-sm font-semibold text-white hover:bg-stone-700">Send number</button>
|
||||
</form>
|
||||
@@ -78,11 +79,24 @@
|
||||
(function () {
|
||||
var code = {{.Data.TagCode | printf "%q"}};
|
||||
var btn = document.getElementById("recheck-btn");
|
||||
// Lightweight anti-spam fingerprint (UA/language/timezone/screen/platform hash).
|
||||
function fingerprint() {
|
||||
var parts = [navigator.userAgent, navigator.language, new Date().getTimezoneOffset(),
|
||||
screen.width + "x" + screen.height, navigator.platform || "", navigator.hardwareConcurrency || ""];
|
||||
return parts.join("|").split("").reduce(function (h, c) {
|
||||
return ((h << 5) - h + c.charCodeAt(0)) | 0;
|
||||
}, 0).toString(36);
|
||||
}
|
||||
var fp = fingerprint();
|
||||
var fpInput = document.querySelector('input[name="fingerprint"]');
|
||||
if (fpInput) fpInput.value = fp;
|
||||
function sendScan(lat, lng) {
|
||||
var body = {fingerprint: fp};
|
||||
if (lat != null) { body.lat = lat; body.lng = lng; }
|
||||
fetch("/t/" + code + "/scan", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify(lat != null ? {lat: lat, lng: lng} : {})
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
}
|
||||
function tryLocation() {
|
||||
|
||||
@@ -1,19 +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
|
||||
- [x] 1.1 `db/schema.sql`: idempotent `ALTER TABLE scans ADD COLUMN IF NOT EXISTS fingerprint TEXT;`; `make db-up`
|
||||
- [x] 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)
|
||||
- [x] 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
|
||||
- [x] 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`
|
||||
- [x] 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
|
||||
- [x] 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
|
||||
- [x] 4.2 Existing suites still pass (Phase 1 + Phase 2) on fresh DB
|
||||
- [x] 4.3 `openspec validate scan-alert-hardening`; commit
|
||||
|
||||
2
todo.txt
2
todo.txt
@@ -8,4 +8,4 @@
|
||||
x 2026-08-05 Set up Gitea for where_woof +project:where-woof +agent:where_woof
|
||||
(B) Scan-flow: re-alert when different finder phone within 250 m window +project:where-woof +agent:right-monitor-pi-coding
|
||||
(B) Scan-flow: browser fingerprint + 24 h block, store in DB +project:where-woof +agent:right-monitor-pi-coding
|
||||
(B) Preset tag-ID registry: seed real manufactured IDs, enforce registry-only (anti-scam) +project:where-woof +agent:right-monitor-pi-coding
|
||||
x 2026-08-07 (B) Preset tag-ID registry: seed real manufactured IDs, enforce registry-only (anti-scam) +project:where-woof +agent:right-monitor-pi-coding
|
||||
|
||||
Reference in New Issue
Block a user