PRIME
One Block, Thousand Futures Secured!
Judol (online gambling) blocking in Indonesia is whack-a-mole: authorities block a domain only after it is live and reported, while operators register replacements in bulk faster than takedowns land. PRIME starts with an empty database and builds its own blocklist — one vision-confirmed site yields an infrastructure fingerprint, and that fingerprint preemptively blocks its siblings, domains nobody has ever visited. Every confirmation propagates to every connected device in real time, so one person's exposure protects everyone, and the system gets more preemptive the more it is used.
PRIME is a GarudaHacks 7.0 (Track: Safety) hackathon project, built by a four-person team over 24 hours.
What is PRIME?
PRIME (the name of the API module is prime) is a two-layer detection system for online gambling domains, delivered today as a Go API plus a Chrome MV3 extension. The core idea is a compounding loop rather than a static list:
- Layer 2 (reactive, the bootstrap path) — a domain nobody has classified yet gets a screenshot sent to a vision model (Gemini). If it looks like gambling, the domain is confirmed and blocked.
- Layer 1 (preemptive, grows from Layer 2) — every Layer 2 confirmation is fingerprinted (hosting IP, ASN, nameserver, registrar, TLD, registration date) and grouped into clusters. Bulk-registration bursts — many domains registered by the same registrar in a narrow time window — are scored deterministically. New, never-visited domains that match a cluster are blocked before anyone opens them.
There is no machine learning in Layer 1 by design: deterministic field-correlation scoring is what lets the block page name the exact fields that matched (matched_fields), instead of handing a user an unexplainable confidence number. The only AI in the system is the Layer 2 vision call, and the project treats that as a means, not the innovation — the innovation is the feedback loop that turns one confirmation into many preemptive blocks.
Features
- Cold-start detection — the database starts empty; every Layer 1 lookup returns no-match until Layer 2 seeds the first cluster
- Layer 2 vision bootstrap — page evidence in, verdict out:
{is_judol, confidence, reason}, backed by Gemini - Layer 1 fingerprint matcher — scores an unseen domain against known clusters and reports which fields matched
- Bulk-registration burst detection — flags domains registered in the same narrow window by the same registrar, the strongest preemptive signal
- Feedback loop (L2 → L1) — a confirmed detection automatically seeds or updates a fingerprint cluster; this loop is what makes the system preemptive at all
- Realtime propagation — a WebSocket relay (
GET /realtime) built on PostgresLISTEN/NOTIFYpushes every new block to all connected clients within seconds - Chrome extension (Manifest V3) — blocks in-browser via
declarativeNetRequest, with a realtime adapter and a 3-second polling fallback behind the same contract - Explainable block page — every block shows a confidence score and plain-language reasons derived from
matched_fields, never a bare number - Cold-start proof, not a marketing number —
GET /bootstrap/latestreports live counters: how many Layer 2 confirmations produced how many Layer 1 preemptive catches - False-positive reporting — a domain can be unblocked client-side immediately and flagged server-side, no waiting on the next sync
Tech Stack
| Layer | Technology |
|---|---|
| Core detection | Go 1.25+ (stdlib only — no HTTP framework, no Chrome, no realtime imports) |
| API transport | Go net/http (stdlib ServeMux) via pgx/v5 |
| Vision (Layer 2) | Gemini 2.x Flash vision API |
| WHOIS / DNS / RDAP | Go net + net/http + raw WHOIS over net.Dial — no third-party client |
| Database | PostgreSQL (Supabase) — pooler connection for queries, direct connection for LISTEN/NOTIFY |
| Realtime transport | WebSocket (nhooyr.io/websocket) fed by a Postgres trigger + NOTIFY |
| Blocker | Chrome Extension, Manifest V3, vanilla JS, declarativeNetRequest |
| Presentation | Plain HTML/CSS block page, shipped inside the extension bundle |
Architecture
Module split. The system is deliberately split into three modules so the browser is only one delivery surface — the roadmap target is Android (VpnService), where the Blocker and Presentation layers change completely and the Core Engine does not.
Core Engine (core/) Detection only. Emits a Verdict.
│ Knows nothing about Chrome, realtime, or UI.
▼
Blocker Service (blocker/, api/) Enforcement + verdict transport.
│ declarativeNetRequest + realtime/polling adapters.
▼
Presentation Layer Block page + dashboard surfaces.
Reads verdicts, never writes them.
The compounding loop.
Empty database
└── fingerprint_clusters is EMPTY
└── every Layer 1 lookup returns no-match, by construction
└── every first visit to a domain falls through to Layer 2
Layer 2 confirms a domain (screenshot → Gemini → verdict)
└── verdict written: status='blocked', source='L2'
└── feedback loop extracts a fingerprint → seeds/updates a cluster
└── Layer 1 now has something to correlate against
Layer 1 scores an unvisited domain against clusters
└── match: same hosting IP, same registration-window burst
└── domain blocked before the page ever renders
└── block page explains why, using matched_fields directly
Realtime propagation.
Device 1 confirms domain X (Layer 2)
└── trigger on domains fires pg_notify('domain_blocked', ...)
└── Go API's GET /realtime relay (LISTEN on DATABASE_DIRECT_URL)
└── fans out to every connected WebSocket client
└── Device 2, never having visited X, blocks it within seconds
The Blocker never talks to Postgres directly. /blocklist, /check, and /realtime are the single access surface — this keeps database credentials out of the extension entirely.
API Reference
Base URL: /api/v1 · JSON for every route except GET /realtime, which is a WebSocket upgrade · no auth in the MVP (see Security below).
GET /blocklist ?since=<ISO> -> {domains:[...], updated_at}
POST /check {domain} -> {status, confidence, source, reason}
POST /analyze {domain, evidence_b64} -> {is_judol, confidence, reason, domain_id}
POST /fingerprint {domain} -> {cluster_id, registrar, ip, ns, tld, match_score, matched_fields}
GET /domains ?limit&offset&source&status -> {items:[...], total}
GET /domains/{id} — -> {domain, detections[], whois, cluster, siblings[], evidence_url}
POST /report-false-positive {domain_id, note} -> {ok:true}
GET /bootstrap/latest — -> {l2_confirmations, l1_preemptive_catches, l1_misses, ratio}
POST /trustpositif/verify {domain} -> {domain, is_blocked} (permanent stub, always false)
GET /realtime — -> WebSocket upgrade, pushes one block event per message
Example — bootstrap a domain through Layer 2:
curl -X POST http://localhost:8000/api/v1/analyze \
-H "Content-Type: application/json" \
-d '{"domain":"gacor88x.xyz","evidence_b64":"<base64 JPEG>","evidence_type":"screenshot"}'
The decision (layer2.Decide) and the Layer 1 feedback loop (core.Feedback, core.MatchSiblings) run in a background goroutine after the response returns — a 200 here does not mean the blocklist is updated yet. Poll GET /blocklist or connect to GET /realtime to observe the result.
POST /trustpositif/verify is a permanent stub: trustpositif.komdigi.go.id's search form requires solving a reCAPTCHA token client-side, which this project will not automate. It exists only so callers that expect it in the contract never need special-casing — never present it as real corroboration.
Module Boundaries
Ownership is enforced by import discipline, not tooling — core/ importing anything Chrome-shaped, realtime-shaped, or UI-shaped breaks the Android portability the whole seam exists for.
| Module | Owns | May import | Endpoints |
|---|---|---|---|
| Core | Detection logic (Layer 1 + Layer 2 + feedback loop) | stdlib only | /analyze, /fingerprint |
| Blocker | Chrome adapter, verdict transport | Core's verdict contract | /blocklist, /check, /realtime |
| Presentation | Block page, dashboard | Blocker/API read surface | /domains, /domains/{id}, /bootstrap/latest |
core/contract.gois the seam. The test before any Core commit: would this file still compile if the extension didn't exist? If no, it belongs somewhere else.
Getting Started
Prerequisites: Go 1.25+, a PostgreSQL/Supabase project, a Gemini API key.
1. Clone the repo and configure the environment:
git clone <this-repo-url>
cd Atraps_PREJUDGE
cp .env.example .env
# fill in SUPABASE_URL, DATABASE_URL, DATABASE_DIRECT_URL, GEMINI_API_KEY
2. Apply the schema (no migration tool — one idempotent schema.sql, safe to re-run):
psql "$DATABASE_URL" -f db/schema.sql
psql "$DATABASE_URL" -f db/fixtures.sql # optional, demo data — purge before any real demo
3. Build and run the API:
go build -o prime-api ./api
./prime-api
# or, without a separate build step:
go run ./api
The server listens on :8000 (hardcoded). Verify it came up:
curl http://localhost:8000/api/v1/blocklist
An empty domains array is expected on a fresh database.
4. Load the Chrome extension:
chrome://extensions -> Developer mode -> Load unpacked -> select blocker/
Run the test suite:
go build ./...
go vet ./...
go test ./...
No test requires a live database or a real Gemini key.
Environment Variables
Copy .env.example to .env. .env is gitignored — never commit real values.
| Variable | Required | Used for |
|---|---|---|
DATABASE_URL |
Yes | Every normal query — Supabase pooler connection string, transaction mode |
DATABASE_DIRECT_URL |
For realtime | Session-scoped connection required for LISTEN/NOTIFY; without it GET /realtime accepts connections but never pushes anything |
GEMINI_API_KEY |
For /analyze |
Layer 2 vision calls. If unset, /analyze degrades to a stub response instead of failing |
GEMINI_MODEL |
No | Overrides the vision model, defaults to gemini-2.0-flash |
GEMINI_CACHE_DIR |
No | Writable directory for fallback verdicts if a live Gemini call fails |
SUPABASE_URL |
No | Not read by the Go API directly; kept for parity with the Supabase dashboard |
SUPABASE_ANON_KEY |
No | Never shipped to the extension — see Security |
SUPABASE_SERVICE_ROLE_KEY |
No | Never leaves api//db/ |
Missing DATABASE_URL does not crash the server — /fingerprint and the domain endpoints report empty state and log a warning instead.
Security
| Aspect | Current state |
|---|---|
| Authentication | None in the MVP — every route under /api/v1 is open |
| CORS | Wildcard (Access-Control-Allow-Origin: *) |
| Credential isolation | The extension holds zero Supabase credentials; SUPABASE_URL/SUPABASE_ANON_KEY/SUPABASE_SERVICE_ROLE_KEY never leave api//db/ — the API is the single point of database access, for both reads and the realtime push |
| Row-level security | Disabled on all tables for the MVP — a locked RLS policy silently breaking realtime mid-demo was judged the worse failure mode |
| Explainability | Layer 1 is deterministic field correlation, not ML, specifically so every block can show the exact matched_fields that triggered it instead of an unexplainable score |
| WebSocket origin | The GET /realtime upgrade currently allows any origin so the unpacked extension can connect during development; this is a known gap to close before any public deployment (see CSWSH note in api/realtime.go) |
Before exposing this beyond a laptop: put an API key or token check in front of every route. None of the above is production-hardened — it reflects the tradeoffs of a 24-hour build running on a single demo machine, documented honestly rather than silently.
Built for GarudaHacks 7.0 — Track: Safety
Built With
- chrome
- css
- gemini
- golang
- html
- https
- javascript
- postgresql
- supabase
- websocket
Log in or sign up for Devpost to join the conversation.