From 1145851c0eb756b609ad0885c2e62c407b3e705b Mon Sep 17 00:00:00 2001 From: Evandro Ribeiro Date: Sat, 31 Oct 2026 20:42:39 +0000 Subject: [PATCH] feat: implement opt out flow with api endpoint, web page and exclusion filters (fixes #23) --- apps/api/src/hackproof/config.py | 4 + apps/api/src/hackproof/export/build.py | 15 ++ apps/api/src/hackproof/hackathon.py | 12 + apps/api/src/hackproof/queries.py | 113 ++++++++-- apps/api/src/hackproof/routes.py | 71 ++++++ apps/api/tests/test_opt_out.py | 211 ++++++++++++++++++ apps/web/src/app.tsx | 2 + .../web/src/components/layout/site-footer.tsx | 13 +- apps/web/src/lib/api.ts | 13 ++ apps/web/src/pages/opt-out.tsx | 168 ++++++++++++++ apps/web/src/pages/project.tsx | 8 +- 11 files changed, 608 insertions(+), 22 deletions(-) create mode 100644 apps/api/tests/test_opt_out.py create mode 100644 apps/web/src/pages/opt-out.tsx diff --git a/apps/api/src/hackproof/config.py b/apps/api/src/hackproof/config.py index 638d0f4..d9c2070 100644 --- a/apps/api/src/hackproof/config.py +++ b/apps/api/src/hackproof/config.py @@ -31,6 +31,10 @@ class Settings(BaseSettings): github_tokens: Annotated[list[str], NoDecode] = Field( default_factory=list, validation_alias="HACKPROOF_GITHUB_TOKEN" ) + opt_out_github_token: str | None = Field( + default=None, + validation_alias=AliasChoices("HACKPROOF_OPT_OUT_GITHUB_TOKEN", "OPT_OUT_GITHUB_TOKEN"), + ) cors_origins: list[str] = ["http://localhost:5173"] @field_validator("github_tokens", mode="before") diff --git a/apps/api/src/hackproof/export/build.py b/apps/api/src/hackproof/export/build.py index 152104c..d7e50b8 100644 --- a/apps/api/src/hackproof/export/build.py +++ b/apps/api/src/hackproof/export/build.py @@ -141,6 +141,21 @@ async def build_export( ) -> HackathonExport: window = hackathon.window projects = await connection.fetch(PROJECTS_SQL, list(tiers), list(TIER_ORDER)) + + hidden = set(hackathon.hidden) + for h in hackathon.hidden: + if h.startswith("repo:"): + hidden.add(h[5:]) + elif not h.startswith("acct:"): + hidden.add(f"repo:{h}") + + projects = [ + row + for row in projects + if row["full_name"] not in hidden + and f"repo:{row['full_name']}" not in hidden + and (not row["owner"] or row["owner"] not in hidden) + ] names = [row["full_name"] for row in projects] span = (window.start, window.end) diff --git a/apps/api/src/hackproof/hackathon.py b/apps/api/src/hackproof/hackathon.py index a6ef8e3..74c5ccc 100644 --- a/apps/api/src/hackproof/hackathon.py +++ b/apps/api/src/hackproof/hackathon.py @@ -36,6 +36,7 @@ class Hackathon(BaseModel): window: Window awards: list[Award] = [] discovery: Discovery + hidden: list[str] = [] @lru_cache @@ -46,3 +47,14 @@ def get_hackathon() -> Hackathon: def places() -> dict[str, Place]: return {award.project_id: award.place for award in get_hackathon().awards} + + +def hidden_ids() -> list[str]: + raw = get_hackathon().hidden + ids = set(raw) + for item in raw: + if item.startswith("repo:"): + ids.add(item[5:]) + elif not item.startswith("acct:"): + ids.add(f"repo:{item}") + return sorted(ids) diff --git a/apps/api/src/hackproof/queries.py b/apps/api/src/hackproof/queries.py index 9f9e1ab..b6fe4d7 100644 --- a/apps/api/src/hackproof/queries.py +++ b/apps/api/src/hackproof/queries.py @@ -2,7 +2,7 @@ from typing import Any, Literal from hackproof.db import pool -from hackproof.hackathon import get_hackathon, places +from hackproof.hackathon import get_hackathon, hidden_ids, places from hackproof.integrations.catalog import get_catalog from hackproof.linking.methods import METHOD_RANK_SQL @@ -31,6 +31,8 @@ SELECT 1 FROM repo_integrations i WHERE i.full_name = p.full_name AND i.partner = ANY($8))) AND ($9::text[] IS NULL OR p.project_id = ANY($9)) + AND ($10::text[] IS NULL OR NOT ( + p.project_id = ANY($10) OR p.full_name = ANY($10) OR r.owner = ANY($10))) ORDER BY array_position( ARRAY['verified', 'likely', 'possible', 'unattributed'], p.tier), p.contracts DESC, p.classic_ops DESC, r.stars DESC NULLS LAST, p.last_activity DESC @@ -50,6 +52,12 @@ OR c.wasm_hash ILIKE '%' || $3 || '%') AND ($4::text IS NULL OR c.deployer = $4) AND ($5::text IS NULL OR COALESCE('repo:' || a.full_name, 'acct:' || c.deployer) = $5) + AND ($8::text[] IS NULL OR NOT ( + COALESCE('repo:' || a.full_name, 'acct:' || c.deployer) = ANY($8) + OR a.full_name = ANY($8) + OR 'acct:' || c.deployer = ANY($8) + OR c.deployer = ANY($8) + )) ORDER BY c.deployed_at DESC LIMIT $6 OFFSET $7 """ @@ -70,6 +78,12 @@ AND ($4::text IS NULL OR w.uploader = $4) AND ($5::text IS NULL OR COALESCE('repo:' || COALESCE(aa.full_name, aw.full_name), 'acct:' || w.uploader) = $5) + AND ($8::text[] IS NULL OR NOT ( + COALESCE('repo:' || COALESCE(aa.full_name, aw.full_name), 'acct:' || w.uploader) = ANY($8) + OR COALESCE(aa.full_name, aw.full_name) = ANY($8) + OR 'acct:' || w.uploader = ANY($8) + OR w.uploader = ANY($8) + )) ORDER BY w.uploaded_at DESC LIMIT $6 OFFSET $7 """ @@ -86,35 +100,69 @@ OR f.result_code ILIKE '%' || $3 || '%') AND ($4::text IS NULL OR f.source_account = $4) AND ($5::text IS NULL OR COALESCE(ap.project_id, 'acct:' || f.source_account) = $5) + AND ($8::text[] IS NULL OR NOT ( + COALESCE(ap.project_id, 'acct:' || f.source_account) = ANY($8) + OR 'acct:' || f.source_account = ANY($8) + OR f.source_account = ANY($8) + )) ORDER BY f.created_at DESC LIMIT $6 OFFSET $7 """ STATS_SQL = """ SELECT - (SELECT count(*) FROM projects WHERE tier IN ('verified', 'likely')) AS projects, - (SELECT count(*) FROM projects WHERE tier = 'verified') AS verified_projects, - (SELECT count(*) FROM projects WHERE tier = 'likely') AS likely_projects, - (SELECT count(*) FROM projects WHERE tier = 'possible') AS possible_projects, - (SELECT count(*) FROM projects WHERE tier = 'unattributed') AS unattributed_accounts, - (SELECT count(*) FROM contracts WHERE deployed_at BETWEEN $1 AND $2) AS contracts, + (SELECT count(*) FROM projects WHERE tier IN ('verified', 'likely') + AND ($3::text[] IS NULL OR NOT (project_id = ANY($3) OR full_name = ANY($3)))) + AS projects, + (SELECT count(*) FROM projects WHERE tier = 'verified' + AND ($3::text[] IS NULL OR NOT (project_id = ANY($3) OR full_name = ANY($3)))) + AS verified_projects, + (SELECT count(*) FROM projects WHERE tier = 'likely' + AND ($3::text[] IS NULL OR NOT (project_id = ANY($3) OR full_name = ANY($3)))) + AS likely_projects, + (SELECT count(*) FROM projects WHERE tier = 'possible' + AND ($3::text[] IS NULL OR NOT (project_id = ANY($3) OR full_name = ANY($3)))) + AS possible_projects, + (SELECT count(*) FROM projects WHERE tier = 'unattributed' + AND ($3::text[] IS NULL OR NOT (project_id = ANY($3) OR full_name = ANY($3)))) + AS unattributed_accounts, + (SELECT count(*) FROM contracts c WHERE deployed_at BETWEEN $1 AND $2 + AND ($3::text[] IS NULL OR NOT ( + 'acct:' || c.deployer = ANY($3) + OR c.deployer = ANY($3) + OR c.contract_id IN ( + SELECT subject FROM attribution + WHERE subject_kind = 'contract' + AND ('repo:' || full_name = ANY($3) OR full_name = ANY($3)) + ) + ))) AS contracts, (SELECT count(*) FROM ( SELECT deployer FROM contracts WHERE deployed_at BETWEEN $1 AND $2 - UNION SELECT uploader FROM wasm_uploads WHERE uploaded_at BETWEEN $1 AND $2) builders) - AS builders, + UNION SELECT uploader FROM wasm_uploads WHERE uploaded_at BETWEEN $1 AND $2) builders + WHERE $3::text[] IS NULL OR NOT ('acct:' || deployer = ANY($3) OR deployer = ANY($3))) + AS builders, (SELECT count(DISTINCT c.login) FROM repo_contributors c JOIN projects p ON p.full_name = c.full_name - WHERE p.tier IN ('verified', 'likely')) AS contributors, + WHERE p.tier IN ('verified', 'likely') + AND ($3::text[] IS NULL OR NOT (p.project_id = ANY($3) OR p.full_name = ANY($3)))) + AS contributors, (SELECT count(DISTINCT country) FROM ( SELECT r.country FROM projects p JOIN repos r ON r.full_name = p.full_name WHERE p.tier IN ('verified', 'likely') + AND ($3::text[] IS NULL OR NOT (p.project_id = ANY($3) OR p.full_name = ANY($3))) UNION SELECT u.country FROM projects p JOIN repo_contributors c ON c.full_name = p.full_name JOIN github_users u ON u.login = c.login - WHERE p.tier IN ('verified', 'likely')) found + WHERE p.tier IN ('verified', 'likely') + AND ($3::text[] IS NULL OR NOT (p.project_id = ANY($3) OR p.full_name = ANY($3)))) found WHERE country IS NOT NULL) AS countries, - (SELECT count(*) FROM wasm_uploads WHERE uploaded_at BETWEEN $1 AND $2) AS wasm_uploads, - (SELECT count(*) FROM failed_txs WHERE created_at BETWEEN $1 AND $2) AS failed_txs + (SELECT count(*) FROM wasm_uploads WHERE uploaded_at BETWEEN $1 AND $2 + AND ($3::text[] IS NULL OR NOT ('acct:' || uploader = ANY($3) OR uploader = ANY($3)))) + AS wasm_uploads, + (SELECT count(*) FROM failed_txs WHERE created_at BETWEEN $1 AND $2 + AND ($3::text[] IS NULL OR NOT ( + 'acct:' || source_account = ANY($3) OR source_account = ANY($3)))) + AS failed_txs """ LINKS_SQL = f""" @@ -147,6 +195,7 @@ SELECT i.partner AS slug, count(DISTINCT i.full_name) AS projects FROM repo_integrations i JOIN projects p ON p.full_name = i.full_name AND p.tier IN ('verified', 'likely') +WHERE ($1::text[] IS NULL OR NOT (p.project_id = ANY($1) OR p.full_name = ANY($1))) GROUP BY i.partner """ @@ -163,6 +212,7 @@ JOIN projects p ON p.full_name = c.full_name AND p.tier IN ('verified', 'likely') JOIN github_users u ON u.login = c.login WHERE u.country IS NOT NULL + AND ($2::text[] IS NULL OR NOT (p.project_id = ANY($2) OR p.full_name = ANY($2))) GROUP BY u.country ORDER BY contributors DESC, u.country LIMIT $1 @@ -183,6 +233,19 @@ def window() -> tuple[datetime, datetime]: return window.start, window.end +def is_hidden(target: str | None) -> bool: + if not target: + return False + hidden = set(hidden_ids()) + return ( + target in hidden + or f"repo:{target}" in hidden + or f"acct:{target}" in hidden + or target.removeprefix("repo:") in hidden + or target.removeprefix("acct:") in hidden + ) + + async def list_page( resource: Resource, *, @@ -196,12 +259,15 @@ async def list_page( limit: int = 50, offset: int = 0, ) -> tuple[list[dict[str, Any]], int]: + if is_hidden(project_id) or is_hidden(account): + return [], 0 + hidden = hidden_ids() or None awards = places() winner_ids = list(awards) if winners else None args = ( - (q, account, project_id, tiers, limit, offset, track, integrations, winner_ids) + (q, account, project_id, tiers, limit, offset, track, integrations, winner_ids, hidden) if resource == "projects" - else (*window(), q, account, project_id, limit, offset) + else (*window(), q, account, project_id, limit, offset, hidden) ) async with pool().acquire() as connection: rows = await connection.fetch(RESOURCE_SQL[resource], *args) @@ -214,16 +280,21 @@ async def list_page( async def fetch_stats() -> dict[str, int]: start, end = window() + hidden = hidden_ids() or None async with pool().acquire() as connection: - return dict(await connection.fetchrow(STATS_SQL, start, end)) + return dict(await connection.fetchrow(STATS_SQL, start, end, hidden)) async def fetch_links(full_name: str) -> list[dict[str, Any]]: + if is_hidden(full_name): + return [] async with pool().acquire() as connection: return [dict(row) for row in await connection.fetch(LINKS_SQL, full_name)] async def fetch_contributors(full_name: str) -> list[dict[str, Any]]: + if is_hidden(full_name): + return [] async with pool().acquire() as connection: return [dict(row) for row in await connection.fetch(CONTRIBUTORS_SQL, full_name)] @@ -235,6 +306,8 @@ async def fetch_timeline() -> list[dict[str, Any]]: async def fetch_integration_uses(full_name: str) -> list[dict[str, Any]]: + if is_hidden(full_name): + return [] async with pool().acquire() as connection: rows = await connection.fetch(INTEGRATION_USES_SQL, full_name) names = {partner.slug: partner.name for partner in get_catalog().partners} @@ -242,8 +315,11 @@ async def fetch_integration_uses(full_name: str) -> list[dict[str, Any]]: async def fetch_integrations() -> list[dict[str, Any]]: + hidden = hidden_ids() or None async with pool().acquire() as connection: - counts = {row["slug"]: row["projects"] for row in await connection.fetch(INTEGRATIONS_SQL)} + counts = { + row["slug"]: row["projects"] for row in await connection.fetch(INTEGRATIONS_SQL, hidden) + } return [ { **partner.model_dump(include={"slug", "name", "category", "site"}), @@ -254,5 +330,6 @@ async def fetch_integrations() -> list[dict[str, Any]]: async def fetch_countries(limit: int = 12) -> list[dict[str, Any]]: + hidden = hidden_ids() or None async with pool().acquire() as connection: - return [dict(row) for row in await connection.fetch(COUNTRIES_SQL, limit)] + return [dict(row) for row in await connection.fetch(COUNTRIES_SQL, limit, hidden)] diff --git a/apps/api/src/hackproof/routes.py b/apps/api/src/hackproof/routes.py index 1c17422..b27d0ef 100644 --- a/apps/api/src/hackproof/routes.py +++ b/apps/api/src/hackproof/routes.py @@ -1,8 +1,12 @@ from typing import Annotated, Any, Literal +import httpx from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel from hackproof import queries +from hackproof.config import get_settings +from hackproof.queries import is_hidden from hackproof.schemas import ( Builder, Contract, @@ -95,6 +99,8 @@ async def _everything(resource: queries.Resource, **filters: str) -> list[dict[s @router.get("/projects/{project_id:path}") async def project(project_id: str) -> ProjectDetail: + if is_hidden(project_id): + raise HTTPException(status_code=404, detail="project not found") found = await _everything("projects", project_id=project_id) if not found: raise HTTPException(status_code=404, detail="project not found") @@ -117,6 +123,8 @@ async def project(project_id: str) -> ProjectDetail: @router.get("/builders/{address}") async def builder(address: str) -> Builder: + if is_hidden(address): + raise HTTPException(status_code=404, detail="no activity in the hack window") result = Builder( address=address, projects=await _everything("projects", account=address), @@ -127,3 +135,66 @@ async def builder(address: str) -> Builder: if not (result.projects or result.contracts or result.wasm_uploads or result.failed_txs): raise HTTPException(status_code=404, detail="no activity in the hack window") return result + + +class OptOutRequest(BaseModel): + project_id: str + handle: str + reason: str | None = None + + +class OptOutResponse(BaseModel): + status: str = "ok" + message: str = "We honor opt outs within 24h." + issue_url: str | None = None + + +@router.post("/opt-out") +async def opt_out(req: OptOutRequest) -> OptOutResponse: + project_id = req.project_id.strip() + handle = req.handle.strip().lstrip("@") + if not project_id or not handle: + raise HTTPException(status_code=400, detail="project_id and handle are required") + + settings = get_settings() + token = settings.opt_out_github_token or ( + settings.github_tokens[0] if settings.github_tokens else None + ) + + issue_title = f"Opt out request: {project_id}" + issue_body = ( + f"### Opt Out Request\n\n" + f"- **Project ID**: {project_id}\n" + f"- **GitHub Handle**: @{handle}\n" + f"- **Reason**: {req.reason or 'Not specified'}\n\n" + f"Please remove this project from the index.\n\n" + f"> We honor opt outs within 24h." + ) + + issue_url = None + if token: + try: + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.post( + "https://api.github.com/repos/devodii/hackproof/issues", + headers={ + "Authorization": f"token {token}", + "Accept": "application/vnd.github+json", + "User-Agent": "HackProof-OptOut", + }, + json={ + "title": issue_title, + "body": issue_body, + "labels": ["opt-out"], + }, + ) + if resp.status_code == 201: + issue_url = resp.json().get("html_url") + except Exception: + pass + + return OptOutResponse( + status="ok", + message="We honor opt outs within 24h.", + issue_url=issue_url, + ) diff --git a/apps/api/tests/test_opt_out.py b/apps/api/tests/test_opt_out.py new file mode 100644 index 0000000..59903d2 --- /dev/null +++ b/apps/api/tests/test_opt_out.py @@ -0,0 +1,211 @@ +from datetime import UTC, datetime +from pathlib import Path +from unittest.mock import AsyncMock + +import pytest +from fastapi.testclient import TestClient + +from hackproof.chat.tools import run_tool +from hackproof.config import Settings +from hackproof.export.files import write_project_files +from hackproof.export.models import Chain, HackathonExport, Owner, Project +from hackproof.export.models import Hackathon as ExportedHackathon +from hackproof.hackathon import Discovery, Hackathon, Window, hidden_ids +from hackproof.main import app +from hackproof.queries import is_hidden, list_page + + +@pytest.fixture(autouse=True) +def mock_pool_lifespan(monkeypatch): + monkeypatch.setattr("hackproof.main.open_pool", AsyncMock()) + monkeypatch.setattr("hackproof.main.close_pool", AsyncMock()) + + +def make_hackathon(hidden: list[str] | None = None) -> Hackathon: + now = datetime.now(UTC) + return Hackathon( + slug="test-hack", + name="Test Hack", + window=Window(start=now, pull_start=now, end=now), + discovery=Discovery( + github_queries=[], + github_code_queries=[], + github_string_queries=[], + dependency_fingerprints={}, + exclude_owners=[], + known_infra_accounts=[], + ), + hidden=hidden or [], + ) + + +def test_hackathon_hidden_defaults_and_normalizes(monkeypatch): + h = make_hackathon(hidden=["repo:owner/app", "plain/name", "acct:G123"]) + monkeypatch.setattr("hackproof.hackathon.get_hackathon", lambda: h) + + ids = hidden_ids() + assert "owner/app" in ids + assert "repo:owner/app" in ids + assert "plain/name" in ids + assert "repo:plain/name" in ids + assert "acct:G123" in ids + + +def test_is_hidden_detection(monkeypatch): + h = make_hackathon(hidden=["repo:secret/repo", "GDEPLOYER"]) + monkeypatch.setattr("hackproof.hackathon.get_hackathon", lambda: h) + + assert is_hidden("secret/repo") + assert is_hidden("repo:secret/repo") + assert is_hidden("GDEPLOYER") + assert is_hidden("acct:GDEPLOYER") + assert not is_hidden("public/repo") + assert not is_hidden("GPUBLIC") + + +def test_opt_out_endpoint_validates_input(): + with TestClient(app) as client: + resp = client.post("/api/opt-out", json={"project_id": "", "handle": ""}) + assert resp.status_code == 400 + + resp2 = client.post("/api/opt-out", json={"project_id": "repo:a/b", "handle": ""}) + assert resp2.status_code == 400 + + +def test_opt_out_endpoint_creates_github_issue(monkeypatch): + mock_resp = AsyncMock() + mock_resp.status_code = 201 + mock_resp.json = lambda: {"html_url": "https://github.com/devodii/hackproof/issues/999"} + mock_post = AsyncMock(return_value=mock_resp) + + test_settings = Settings( + opt_out_github_token="fake-token", + github_tokens=["token1"], + ) + monkeypatch.setattr("hackproof.routes.get_settings", lambda: test_settings) + monkeypatch.setattr("httpx.AsyncClient.post", mock_post) + + with TestClient(app) as client: + res = client.post( + "/api/opt-out", + json={ + "project_id": "repo:privacy/project", + "handle": "private_builder", + "reason": "Requesting project removal", + }, + ) + assert res.status_code == 200 + data = res.json() + assert data["status"] == "ok" + assert "We honor opt outs within 24h." in data["message"] + assert data["issue_url"] == "https://github.com/devodii/hackproof/issues/999" + + # Verify call arguments + assert mock_post.called + kwargs = mock_post.call_args.kwargs + assert "opt-out" in kwargs["json"]["labels"] + assert "Opt out request: repo:privacy/project" in kwargs["json"]["title"] + assert "@private_builder" in kwargs["json"]["body"] + assert "We honor opt outs within 24h." in kwargs["json"]["body"] + + +async def test_hidden_id_never_appears_in_api_response(monkeypatch): + h = make_hackathon(hidden=["repo:stealth/app", "GSTEALTH"]) + monkeypatch.setattr("hackproof.hackathon.get_hackathon", lambda: h) + + with TestClient(app) as client: + # 1. Project detail endpoint returns 404 for hidden project + resp = client.get("/api/projects/repo:stealth/app") + assert resp.status_code == 404 + + # 2. Builder detail endpoint returns 404 for hidden account + resp_builder = client.get("/api/builders/GSTEALTH") + assert resp_builder.status_code == 404 + + # 3. Direct queries.list_page returns empty + items, total = await list_page("projects", project_id="repo:stealth/app") + assert items == [] + assert total == 0 + + items_c, total_c = await list_page("contracts", project_id="repo:stealth/app") + assert items_c == [] + assert total_c == 0 + + items_b, total_b = await list_page("projects", account="GSTEALTH") + assert items_b == [] + assert total_b == 0 + + +def test_hidden_id_never_appears_in_export(tmp_path: Path): + now = datetime.now(UTC) + export_hack = ExportedHackathon( + slug="test-hack", + name="Test Hack", + network="testnet", + starts_at=now, + submission_deadline=now, + ) + p_visible = Project( + repo_full_name="public/app", + repo_url="https://github.com/public/app", + name="app", + description="Public app", + homepage=None, + tier="verified", + track="genesis", + track_declared=True, + award=None, + language="Rust", + license="MIT", + stars=10, + forks=2, + self_tags=["stellar"], + stack=["@stellar/stellar-sdk"], + stack_versions={"@stellar/stellar-sdk": "14.0.0"}, + repo_created_at=now, + repo_last_commit_at=now, + commits_in_window=5, + total_commits=10, + owner=Owner(login="public_user", avatar_url=None, country=None), + chain=Chain( + contract_count=1, + wasm_upload_count=1, + classic_op_count=0, + asset_count=0, + failed_tx_count=0, + accounts=[], + wasm_uploads=[], + contracts=[], + evidence=[], + ), + contributors=[], + integration_partners=[], + ) + export = HackathonExport(hackathon=export_hack, projects=[p_visible]) + + # Write files + written = write_project_files(export, tmp_path) + assert written == 1 + + # Check that stealth/app YAML was never generated + generated_files = [f.name for f in tmp_path.glob("**/*.yml")] + assert any("app.yml" in f for f in generated_files) + assert not any("stealth" in f for f in generated_files) + + +async def test_hidden_id_never_appears_in_chat_tools(monkeypatch): + h = make_hackathon(hidden=["repo:stealth/app", "GSTEALTH"]) + monkeypatch.setattr("hackproof.hackathon.get_hackathon", lambda: h) + + # 1. get_project returns error when hidden + res = await run_tool("get_project", {"project_id": "repo:stealth/app"}) + assert res == {"error": "project not found"} + + # 2. get_builder returns error when hidden + res_b = await run_tool("get_builder", {"address": "GSTEALTH"}) + assert res_b == {"error": "no activity in the hack window"} + + # 3. query_contracts with hidden project_id returns empty + res_c = await run_tool("query_contracts", {"project_id": "repo:stealth/app"}) + assert res_c["total"] == 0 + assert res_c["items"] == [] diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index 1ab34f3..e1fa06d 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -9,6 +9,7 @@ import { SiteFooter } from '@/components/layout/site-footer' import { SiteHeader } from '@/components/layout/site-header' import { BuilderPage } from '@/pages/builder' import { HomePage } from '@/pages/home' +import { OptOutPage } from '@/pages/opt-out' import { ProjectPage } from '@/pages/project' function Shell() { @@ -26,6 +27,7 @@ function Shell() { } /> } /> } /> + } /> diff --git a/apps/web/src/components/layout/site-footer.tsx b/apps/web/src/components/layout/site-footer.tsx index 728638c..eb639a1 100644 --- a/apps/web/src/components/layout/site-footer.tsx +++ b/apps/web/src/components/layout/site-footer.tsx @@ -1,3 +1,5 @@ +import { Link } from 'react-router' + import { REPO_URL } from '@/lib/config' export function SiteFooter() { @@ -5,9 +7,14 @@ export function SiteFooter() { ) diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 451aee7..f123bdd 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -17,3 +17,16 @@ export async function apiGet(path: string): Promise { } return response.json() as Promise } + +export async function apiPost(path: string, body: unknown): Promise { + const response = await fetch(`${API_URL}/api${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + if (!response.ok) { + const data = await response.json().catch(() => null) + throw new ApiError(response.status, data?.detail ?? response.statusText) + } + return response.json() as Promise +} diff --git a/apps/web/src/pages/opt-out.tsx b/apps/web/src/pages/opt-out.tsx new file mode 100644 index 0000000..aa7a4c9 --- /dev/null +++ b/apps/web/src/pages/opt-out.tsx @@ -0,0 +1,168 @@ +import { useState } from 'react' +import { Link, useSearchParams } from 'react-router' +import { CheckCircle2Icon, ShieldAlertIcon } from 'lucide-react' + +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { Button, buttonVariants } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' +import { apiPost } from '@/lib/api' + +interface OptOutResponse { + status: string + message: string + issue_url?: string | null +} + +export function OptOutPage() { + const [searchParams] = useSearchParams() + const initialProjectId = searchParams.get('project_id') ?? searchParams.get('projectId') ?? '' + + const [projectId, setProjectId] = useState(initialProjectId) + const [handle, setHandle] = useState('') + const [reason, setReason] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + const [result, setResult] = useState(null) + const [error, setError] = useState(null) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!projectId.trim() || !handle.trim()) { + setError('Please provide both a project ID and your GitHub handle.') + return + } + + setIsSubmitting(true) + setError(null) + + try { + const response = await apiPost('/opt-out', { + project_id: projectId.trim(), + handle: handle.trim(), + reason: reason.trim() || undefined, + }) + setResult(response) + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Failed to submit opt out request.' + setError(msg) + } finally { + setIsSubmitting(false) + } + } + + return ( +
+ + +
+ + Opt Out of Index +
+ + Anyone can request the removal of their project or handle from the HackProof index. We + honor opt outs within 24h. + +
+ + {result ? ( +
+ + + Opt out request received + + {result.message} Your request for project{' '} + {projectId} has been logged. + {result.issue_url && ( + + )} + + +
+ + Back to Home + +
+
+ ) : ( +
+ {error && ( + + Error + {error} + + )} +
+ + setProjectId(e.target.value)} + required + /> + + The repository identifier or account address listed in HackProof. + +
+ +
+ + setHandle(e.target.value)} + required + /> + + Your GitHub handle to verify project ownership or attribution. + +
+ +
+ +