Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .claude/skills/private_hackathons.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Private Hackathon Whitelist (Quick Ops)

Use `allowed_users` to make a leaderboard opt-in.

## Semantics

- `allowed_users = null` → leaderboard is open to everyone
- `allowed_users = []` → whitelist is enabled but empty (nobody can submit)
- `allowed_users = ["alice", "bob"]` → only those usernames can submit

## Setup

```bash
export API_URL="https://discord-cluster-manager-1f6c4782e60a.herokuapp.com"
export TOKEN="<admin-token>"
```

## Commands

### View current whitelist

```bash
curl -s "$API_URL/admin/leaderboards/helion-matmul/allowed-users" \
-H "Authorization: Bearer $TOKEN" | python3 -m json.tool
```

### Replace full whitelist

```bash
curl -X PUT "$API_URL/admin/leaderboards/helion-matmul/allowed-users" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"usernames": ["alice", "bob", "charlie"]}'
```

### Append users

```bash
curl -X POST "$API_URL/admin/leaderboards/helion-matmul/allowed-users" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"usernames": ["dave", "eve"]}'
```

### Remove users

```bash
curl -X DELETE "$API_URL/admin/leaderboards/helion-matmul/allowed-users" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"usernames": ["eve"]}'
```

### Re-open leaderboard (disable whitelist)

```bash
curl -X PUT "$API_URL/admin/leaderboards/helion-matmul/allowed-users" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"usernames": null}'
```
8 changes: 8 additions & 0 deletions src/kernelbot/api/api_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,14 @@ async def to_submit_info(
try:
with db_context as db:
leaderboard_item = db.get_leaderboard(leaderboard_name)

allowed = leaderboard_item.get("allowed_users")
if allowed is not None and user_name not in allowed:
raise HTTPException(
status_code=403,
detail="You are not authorized to submit to this leaderboard.",
)

gpus = leaderboard_item.get("gpu_types", [])
if gpu_type not in gpus:
supported_gpus = ", ".join(gpus) if gpus else "None"
Expand Down
72 changes: 72 additions & 0 deletions src/kernelbot/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -675,6 +675,78 @@ async def admin_update_problems(
}


@app.get("/admin/leaderboards/{leaderboard_name}/allowed-users")
async def get_allowed_users(
leaderboard_name: str,
_: Annotated[None, Depends(require_admin)],
db_context=Depends(get_db),
) -> dict:
with db_context as db:
leaderboard = db.get_leaderboard(leaderboard_name)
return {"leaderboard": leaderboard_name, "allowed_users": leaderboard.get("allowed_users")}


def _normalize_usernames_payload(payload: dict) -> list[str]:
if "usernames" not in payload:
raise HTTPException(status_code=400, detail="Missing required field: usernames")
usernames = payload["usernames"]
if not isinstance(usernames, list):
raise HTTPException(status_code=400, detail="usernames must be a list of strings")
if not all(isinstance(u, str) for u in usernames):
raise HTTPException(status_code=400, detail="usernames must be a list of strings")
return sorted({u.strip() for u in usernames if u.strip()})


@app.put("/admin/leaderboards/{leaderboard_name}/allowed-users")
async def set_allowed_users(
leaderboard_name: str,
payload: dict,
_: Annotated[None, Depends(require_admin)],
db_context=Depends(get_db),
) -> dict:
if "usernames" not in payload:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

slop; that's the helper above

raise HTTPException(status_code=400, detail="Missing required field: usernames")

usernames = payload["usernames"]
if usernames is not None and not isinstance(usernames, list):
raise HTTPException(status_code=400, detail="usernames must be a list of strings or null")
if isinstance(usernames, list) and not all(isinstance(u, str) for u in usernames):
raise HTTPException(status_code=400, detail="usernames must be a list of strings or null")

if isinstance(usernames, list):
usernames = sorted({u.strip() for u in usernames if u.strip()})

with db_context as db:
db.set_allowed_users(leaderboard_name, usernames)
return {"status": "ok", "leaderboard": leaderboard_name, "allowed_users": usernames}


@app.post("/admin/leaderboards/{leaderboard_name}/allowed-users")
async def append_allowed_users(
leaderboard_name: str,
payload: dict,
_: Annotated[None, Depends(require_admin)],
db_context=Depends(get_db),
) -> dict:
usernames = _normalize_usernames_payload(payload)
with db_context as db:
updated = db.append_allowed_users(leaderboard_name, usernames)
return {"status": "ok", "leaderboard": leaderboard_name, "allowed_users": updated}


@app.delete("/admin/leaderboards/{leaderboard_name}/allowed-users")
async def delete_allowed_users(
leaderboard_name: str,
payload: dict,
_: Annotated[None, Depends(require_admin)],
db_context=Depends(get_db),
) -> dict:
usernames = _normalize_usernames_payload(payload)
with db_context as db:
updated = db.remove_allowed_users(leaderboard_name, usernames)
return {"status": "ok", "leaderboard": leaderboard_name, "allowed_users": updated}


@app.get("/leaderboards")
async def get_leaderboards(db_context=Depends(get_db)):
"""An endpoint that returns all leaderboards.
Expand Down
16 changes: 16 additions & 0 deletions src/kernelbot/cogs/leaderboard_cog.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,22 @@ async def submit(
)
req = prepare_submission(req, self.bot.backend)

# Check if leaderboard has an allowlist; if so, require a matching Discord role
with self.bot.leaderboard_db as db:
lb_item = db.get_leaderboard(req.leaderboard)
allowed_users = lb_item.get("allowed_users")
if allowed_users:
# Derive expected role name from leaderboard name prefix (e.g. "helion-foo" -> "helion")
role_name = req.leaderboard.split("-")[0] if "-" in req.leaderboard else req.leaderboard
member_roles = [r.name.lower() for r in interaction.user.roles]
if role_name.lower() not in member_roles:
await send_discord_message(
interaction,
f"You need the `{role_name}` role to submit to this leaderboard.",
ephemeral=True,
)
return -1

if req.gpus is None:
view = await self.select_gpu_view(interaction, leaderboard_name, req.task_gpus)
req.gpus = view.selected_gpus
Expand Down
1 change: 1 addition & 0 deletions src/libkernelbot/db_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class LeaderboardItem(TypedDict):
gpu_types: List[str]
forum_id: int
secret_seed: NotRequired[int]
allowed_users: NotRequired[Optional[List[str]]]


class LeaderboardRankedEntry(TypedDict):
Expand Down
71 changes: 69 additions & 2 deletions src/libkernelbot/leaderboard_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -588,7 +588,7 @@ def get_leaderboard_templates(self, leaderboard_name: str) -> Dict[str, str]:
def get_leaderboard(self, leaderboard_name: str) -> "LeaderboardItem":
self.cursor.execute(
"""
SELECT id, name, deadline, task, creator_id, forum_id, secret_seed, description
SELECT id, name, deadline, task, creator_id, forum_id, secret_seed, description, allowed_users
FROM leaderboard.leaderboard
WHERE name = %s
""",
Expand All @@ -599,7 +599,7 @@ def get_leaderboard(self, leaderboard_name: str) -> "LeaderboardItem":

if res:
task = LeaderboardTask.from_dict(res[3])
return LeaderboardItem(
item = LeaderboardItem(
id=res[0],
name=res[1],
deadline=res[2],
Expand All @@ -610,9 +610,76 @@ def get_leaderboard(self, leaderboard_name: str) -> "LeaderboardItem":
gpu_types=self.get_leaderboard_gpu_types(res[1]),
description=res[7],
)
if res[8] is not None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

make an explicit get function and fetch this only on-demand?

item["allowed_users"] = list(res[8])
return item
else:
raise LeaderboardDoesNotExist(leaderboard_name)

def set_allowed_users(self, leaderboard_name: str, usernames: Optional[List[str]]) -> None:
self.cursor.execute(
"""
UPDATE leaderboard.leaderboard
SET allowed_users = %s
WHERE name = %s
""",
(usernames, leaderboard_name),
)
if self.cursor.rowcount == 0:
raise LeaderboardDoesNotExist(leaderboard_name)
self.connection.commit()

def append_allowed_users(self, leaderboard_name: str, usernames: List[str]) -> List[str]:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fancy SQL vs just processing this on the python side?

self.cursor.execute(
"""
UPDATE leaderboard.leaderboard
SET allowed_users = CASE
WHEN allowed_users IS NULL THEN %s
ELSE (
SELECT ARRAY(
SELECT DISTINCT elem
FROM unnest(allowed_users || %s::text[]) AS elem
ORDER BY elem
)
)
END
WHERE name = %s
RETURNING allowed_users
""",
(usernames, usernames, leaderboard_name),
)
res = self.cursor.fetchone()
if res is None:
raise LeaderboardDoesNotExist(leaderboard_name)
self.connection.commit()
return list(res[0]) if res[0] is not None else []

def remove_allowed_users(self, leaderboard_name: str, usernames: List[str]) -> List[str]:
self.cursor.execute(
"""
UPDATE leaderboard.leaderboard
SET allowed_users = CASE
WHEN allowed_users IS NULL THEN NULL
ELSE (
SELECT ARRAY(
SELECT elem
FROM unnest(allowed_users) AS elem
WHERE NOT (elem = ANY(%s::text[]))
ORDER BY elem
)
)
END
WHERE name = %s
RETURNING allowed_users
""",
(usernames, leaderboard_name),
)
res = self.cursor.fetchone()
if res is None:
raise LeaderboardDoesNotExist(leaderboard_name)
self.connection.commit()
return list(res[0]) if res[0] is not None else None

def get_leaderboard_submissions(
self,
leaderboard_name: str,
Expand Down
21 changes: 21 additions & 0 deletions src/migrations/20260303_01_add-allowed-users.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""
add-allowed-users
"""

from yoyo import step

__depends__ = {"20260226_01_WgYAV-queryindex"}


steps = [
step(
"""
ALTER TABLE leaderboard.leaderboard

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

whats the design trade-off here on having a column vs having an extra table?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Column approach (TEXT[]) is simpler for this use case: the allowlist is always read/written as a whole unit, the list is small (tens of users max), and it avoids the extra join on every submission check. A separate table would make sense if we needed per-user metadata (e.g., added_by, added_at), needed to query across leaderboards ("which leaderboards is alice on?"), or if the lists grew large. For now the column keeps things simple.

ADD COLUMN allowed_users TEXT[] DEFAULT NULL;
""",
"""
ALTER TABLE leaderboard.leaderboard
DROP COLUMN IF EXISTS allowed_users;
""",
),
]
Loading
Loading