DataBounty commited on
Commit
ebd3ce3
·
verified ·
1 Parent(s): 78769c1

Let sponsors request a dataset from the Space, once DataBounty takes requests

Browse files
Files changed (8) hide show
  1. README.md +141 -135
  2. account.py +531 -518
  3. databounty.py +442 -426
  4. requesting.py +209 -0
  5. web/app.css +439 -424
  6. web/app.js +217 -2
  7. web/index.html +2 -2
  8. webapi.py +619 -536
README.md CHANGED
@@ -1,135 +1,141 @@
1
- ---
2
- title: DataBounty
3
- emoji: 🧭
4
- colorFrom: green
5
- colorTo: blue
6
- sdk: gradio
7
- sdk_version: 6.30.0
8
- python_version: "3.12"
9
- app_file: app.py
10
- short_description: Contribute and review dataset items, karma and MCP tools
11
- license: apache-2.0
12
- pinned: true
13
- tags:
14
- - datasets
15
- - data-collection
16
- - crowdsourcing
17
- - mcp-server
18
- datasets:
19
- - databounty-io/graphql-schema-resolver-execution-validation-cmskdigf
20
- - databounty-io/build-competitive-programming-problem-dataset-cmsokff2
21
- - databounty-io/regex-pattern-generation-with-verified-match-sets-cmskdvm7
22
- - databounty-io/extract-metrics-from-log-fixtures-cmskdlip
23
- - databounty-io/python-execution-trace-output-prediction-cmskdimp
24
- - databounty-io/python-data-transformation-scripts-cmskdijz
25
- ---
26
-
27
- # DataBounty
28
-
29
- A live window onto [DataBounty](https://databounty.io), the open-source, karma-based platform where the community
30
- builds datasets, and a place to contribute to it. Sponsors request a dataset, contributors submit items, automated checks validate each
31
- submission and human validators review the results. Finished datasets are published here on the Hub under
32
- [databounty-io](https://huggingface.co/databounty-io), with contributor credit.
33
-
34
- ## What's inside
35
-
36
- It looks and works like the console on databounty.io:
37
-
38
- - **Contribute**: your karma, every open pool, and your submissions. Open a pool to see what it wants and how items
39
- are checked, then add items one at a time or many from a file.
40
- - **Validate**: review other people's items. Claim an open audit, check each item against the pool's spec and its
41
- automated checks, then approve it or reject it with a reason and a note for the contributor.
42
- - **Sponsor**: how requesting a dataset works, with links to make and track requests on databounty.io. If you
43
- requested a pool, its page here also shows you every item in it, and you can dispute an accepted one.
44
- - **Datasets**: DataBounty releases on the Hub, with download counts.
45
- - **Leaderboard**: public karma standings and profiles. Private profiles, and any field a member hides, are never shown.
46
- - **API & MCP**: connect Claude, Cursor or VS Code to DataBounty's own MCP server.
47
- - **About**: live programme totals, the karma tiers and how it all works.
48
-
49
- ## Contributing from the Space
50
-
51
- Press **sign in with DataBounty** and sign in with your DataBounty account, then approve the Space. On the Space's
52
- own page, sign-in happens in the same tab. On the huggingface.co page, a small sign-in window opens and closes itself
53
- when you're done, and the page signs you in where you are. The Space asks for six permissions: read pools and your
54
- own items, contribute items, review other people's items, see the items in pools you requested, upload files for
55
- file fields, and set up your account on first sign-in. You can untick any of them, but the Space can't contribute
56
- without *contribute*, review without *validate*, or show your pools' items without *sponsor*.
57
-
58
- Then pick a pool, and add one item with the form or many at once from a JSONL, JSON, CSV or TSV file (there's a
59
- template to start from). A file is checked row by row before anything is sent. Each pool shows an example item when
60
- it has one, and its full requirements one click away. **My submissions** shows where each item stands, check by check.
61
-
62
- - DataBounty takes items only from accounts with a verified email and a public username. The Space walks you through
63
- both on first sign-in. Choosing a username makes your profile public at `databounty.io/<username>` straight away.
64
- - Your DataBounty tokens stay in the Space's memory and are never written to disk or sent to your browser, which
65
- keeps only a random key to them. Signing out revokes the tokens. A restart of the Space signs everyone out.
66
- - Revisions, contesting a reviewer's verdict, and requesting a dataset happen on [databounty.io](https://databounty.io).
67
-
68
- ## Reviewing from the Space
69
-
70
- Open **Validate** and claim an open audit: a few items from one pool, yours for 24 hours. You never get your own work.
71
- Go through the items one at a time and approve each one, or reject it with a reason and a note of at least 10
72
- characters, which is all the contributor sees. Decisions are final. You earn karma for every decision when the audit
73
- completes, and your validator rank sets how many audits you can hold at once.
74
-
75
- ## Sponsoring from the Space
76
-
77
- Requesting a dataset happens on [databounty.io](https://console.databounty.io/sponsor/create/). Once your pool is
78
- open, sign in here and open the pool's page: below its details you see every item in it, newest first, with its
79
- fields, its automated checks and any reviewer flags, and you can filter by where items stand. While an accepted
80
- item's dispute window is open, you can dispute it with a reason and an explanation of at least 10 characters. An
81
- admin decides, and the contributor's karma is only reversed if the dispute is upheld. Nobody else sees this view:
82
- DataBounty shows a pool's items only to the person who requested it.
83
-
84
- ## Use it from an agent
85
-
86
- This Space is also an MCP server:
87
-
88
- ```text
89
- https://databounty-io-databounty.hf.space/gradio_api/mcp/
90
- ```
91
-
92
- - **Six tools browse, with nothing to set up:** `get_platform_stats`, `search_open_pools`, `get_pool_details`,
93
- `list_published_datasets`, `get_karma_leaderboard` and `get_contributor_profile`.
94
- - **Five tools submit items as you:** `get_my_account`, `get_pool_contract`, `submit_items`,
95
- `list_my_submissions` and `check_submission`. They need a DataBounty API key:
96
- 1. Create one at [console.databounty.io/developers](https://console.databounty.io/developers/) with the
97
- **contribute** scope.
98
- 2. Add it to your MCP client's settings for this server as the `X-DataBounty-Key` header. It never goes in a
99
- message or a tool argument, so the model never sees it.
100
-
101
- The Space passes the key to DataBounty for each call and keeps nothing. Revoke it on DataBounty any time.
102
- - **Two tools for sponsors:** `get_pool_items_as_sponsor` lists the items in a pool you requested, with their
103
- checks, and `dispute_accepted_item` disputes an accepted one. They need a key with the **sponsor** scope and
104
- work only on your own pools.
105
-
106
- Claude Code:
107
-
108
- ```bash
109
- claude mcp add --transport http databounty-space https://databounty-io-databounty.hf.space/gradio_api/mcp/ \
110
- --header "X-DataBounty-Key: db_live_sk_..."
111
- ```
112
-
113
- Cursor (`~/.cursor/mcp.json`):
114
-
115
- ```json
116
- { "mcpServers": { "databounty-space": {
117
- "url": "https://databounty-io-databounty.hf.space/gradio_api/mcp/",
118
- "headers": { "X-DataBounty-Key": "db_live_sk_..." } } } }
119
- ```
120
-
121
- An agent submits at most a pool's per-call limit at once; for more, upload a file on the Space's pool page. The
122
- Hub prefixes each tool name with the Space name (`DataBounty_submit_items` and so on). Reviewing other people's
123
- items, and items with files, go through DataBounty's own MCP server at `https://console.databounty.io/mcp`, which
124
- signs in with OAuth; setup for each client is on [databounty.io/agents](https://databounty.io/agents/).
125
-
126
- ## Data
127
-
128
- Browsing reads DataBounty's public API (`api.databounty.io`) and the Hub, caching responses for about two minutes,
129
- and stores nothing about visitors. When you sign in, the Space acts for you through DataBounty's MCP server, and only
130
- when you press a button. Files you attach pass straight through to DataBounty: the Space doesn't keep them.
131
-
132
- ## Source
133
-
134
- Apache-2.0. The code lives in [Databounty-io/databounty](https://github.com/Databounty-io/databounty) under
135
- `deploy/huggingface/databounty`.
 
 
 
 
 
 
 
1
+ ---
2
+ title: DataBounty
3
+ emoji: 🧭
4
+ colorFrom: green
5
+ colorTo: blue
6
+ sdk: gradio
7
+ sdk_version: 6.30.0
8
+ python_version: "3.12"
9
+ app_file: app.py
10
+ short_description: Contribute and review dataset items, karma and MCP tools
11
+ license: apache-2.0
12
+ pinned: true
13
+ tags:
14
+ - datasets
15
+ - data-collection
16
+ - crowdsourcing
17
+ - mcp-server
18
+ datasets:
19
+ - databounty-io/graphql-schema-resolver-execution-validation-cmskdigf
20
+ - databounty-io/build-competitive-programming-problem-dataset-cmsokff2
21
+ - databounty-io/regex-pattern-generation-with-verified-match-sets-cmskdvm7
22
+ - databounty-io/extract-metrics-from-log-fixtures-cmskdlip
23
+ - databounty-io/python-execution-trace-output-prediction-cmskdimp
24
+ - databounty-io/python-data-transformation-scripts-cmskdijz
25
+ ---
26
+
27
+ # DataBounty
28
+
29
+ A live window onto [DataBounty](https://databounty.io), the open-source, karma-based platform where the community
30
+ builds datasets, and a place to contribute to it. Sponsors request a dataset, contributors submit items, automated checks validate each
31
+ submission and human validators review the results. Finished datasets are published here on the Hub under
32
+ [databounty-io](https://huggingface.co/databounty-io), with contributor credit.
33
+
34
+ ## What's inside
35
+
36
+ It looks and works like the console on databounty.io:
37
+
38
+ - **Contribute**: your karma, every open pool, and your submissions. Open a pool to see what it wants and how items
39
+ are checked, then add items one at a time or many from a file.
40
+ - **Validate**: review other people's items. Claim an open audit, check each item against the pool's spec and its
41
+ automated checks, then approve it or reject it with a reason and a note for the contributor.
42
+ - **Sponsor**: request a dataset, add example items to it and follow it through review. If you requested a pool,
43
+ its page here also shows you every item in it, and you can dispute an accepted one.
44
+ - **Datasets**: DataBounty releases on the Hub, with download counts.
45
+ - **Leaderboard**: public karma standings and profiles. Private profiles, and any field a member hides, are never shown.
46
+ - **API & MCP**: connect Claude, Cursor or VS Code to DataBounty's own MCP server.
47
+ - **About**: live programme totals, the karma tiers and how it all works.
48
+
49
+ ## Contributing from the Space
50
+
51
+ Press **sign in with DataBounty** and sign in with your DataBounty account, then approve the Space. On the Space's
52
+ own page, sign-in happens in the same tab. On the huggingface.co page, a small sign-in window opens and closes itself
53
+ when you're done, and the page signs you in where you are. The Space asks for six permissions: read pools and your
54
+ own items, contribute items, review other people's items, see the items in pools you requested, upload files for
55
+ file fields, and set up your account on first sign-in. You can untick any of them, but the Space can't contribute
56
+ without *contribute*, review without *validate*, or show your pools' items without *sponsor*.
57
+
58
+ Then pick a pool, and add one item with the form or many at once from a JSONL, JSON, CSV or TSV file (there's a
59
+ template to start from). A file is checked row by row before anything is sent. Each pool shows an example item when
60
+ it has one, and its full requirements one click away. **My submissions** shows where each item stands, check by check.
61
+
62
+ - DataBounty takes items only from accounts with a verified email and a public username. The Space walks you through
63
+ both on first sign-in. Choosing a username makes your profile public at `databounty.io/<username>` straight away.
64
+ - Your DataBounty tokens stay in the Space's memory and are never written to disk or sent to your browser, which
65
+ keeps only a random key to them. Signing out revokes the tokens. A restart of the Space signs everyone out.
66
+ - Revisions, contesting a reviewer's verdict, and requesting a dataset happen on [databounty.io](https://databounty.io).
67
+
68
+ ## Reviewing from the Space
69
+
70
+ Open **Validate** and claim an open audit: a few items from one pool, yours for 24 hours. You never get your own work.
71
+ Go through the items one at a time and approve each one, or reject it with a reason and a note of at least 10
72
+ characters, which is all the contributor sees. Decisions are final. You earn karma for every decision when the audit
73
+ completes, and your validator rank sets how many audits you can hold at once.
74
+
75
+ ## Sponsoring from the Space
76
+
77
+ To request a dataset, open **Sponsor**, pick a dataset type, and say how many items you want, how hard, and under
78
+ which licence. Then add up to three example items to the request, as JSON files (each request has a template to
79
+ start from). DataBounty's team reviews the request and its examples, and the pool can open once one example is
80
+ approved. **Your requests** shows where each one stands, with any note from the reviewer. Requesting from the
81
+ Space needs DataBounty's request tool; where it isn't live yet, the Sponsor page links to
82
+ [databounty.io](https://console.databounty.io/sponsor/create/) instead.
83
+
84
+ Once your pool is open, sign in here and open the pool's page: below its details you see every item in it, newest first, with its
85
+ fields, its automated checks and any reviewer flags, and you can filter by where items stand. While an accepted
86
+ item's dispute window is open, you can dispute it with a reason and an explanation of at least 10 characters. An
87
+ admin decides, and the contributor's karma is only reversed if the dispute is upheld. Nobody else sees this view:
88
+ DataBounty shows a pool's items only to the person who requested it.
89
+
90
+ ## Use it from an agent
91
+
92
+ This Space is also an MCP server:
93
+
94
+ ```text
95
+ https://databounty-io-databounty.hf.space/gradio_api/mcp/
96
+ ```
97
+
98
+ - **Six tools browse, with nothing to set up:** `get_platform_stats`, `search_open_pools`, `get_pool_details`,
99
+ `list_published_datasets`, `get_karma_leaderboard` and `get_contributor_profile`.
100
+ - **Five tools submit items as you:** `get_my_account`, `get_pool_contract`, `submit_items`,
101
+ `list_my_submissions` and `check_submission`. They need a DataBounty API key:
102
+ 1. Create one at [console.databounty.io/developers](https://console.databounty.io/developers/) with the
103
+ **contribute** scope.
104
+ 2. Add it to your MCP client's settings for this server as the `X-DataBounty-Key` header. It never goes in a
105
+ message or a tool argument, so the model never sees it.
106
+
107
+ The Space passes the key to DataBounty for each call and keeps nothing. Revoke it on DataBounty any time.
108
+ - **Two tools for sponsors:** `get_pool_items_as_sponsor` lists the items in a pool you requested, with their
109
+ checks, and `dispute_accepted_item` disputes an accepted one. They need a key with the **sponsor** scope and
110
+ work only on your own pools.
111
+
112
+ Claude Code:
113
+
114
+ ```bash
115
+ claude mcp add --transport http databounty-space https://databounty-io-databounty.hf.space/gradio_api/mcp/ \
116
+ --header "X-DataBounty-Key: db_live_sk_..."
117
+ ```
118
+
119
+ Cursor (`~/.cursor/mcp.json`):
120
+
121
+ ```json
122
+ { "mcpServers": { "databounty-space": {
123
+ "url": "https://databounty-io-databounty.hf.space/gradio_api/mcp/",
124
+ "headers": { "X-DataBounty-Key": "db_live_sk_..." } } } }
125
+ ```
126
+
127
+ An agent submits at most a pool's per-call limit at once; for more, upload a file on the Space's pool page. The
128
+ Hub prefixes each tool name with the Space name (`DataBounty_submit_items` and so on). Reviewing other people's
129
+ items, and items with files, go through DataBounty's own MCP server at `https://console.databounty.io/mcp`, which
130
+ signs in with OAuth; setup for each client is on [databounty.io/agents](https://databounty.io/agents/).
131
+
132
+ ## Data
133
+
134
+ Browsing reads DataBounty's public API (`api.databounty.io`) and the Hub, caching responses for about two minutes,
135
+ and stores nothing about visitors. When you sign in, the Space acts for you through DataBounty's MCP server, and only
136
+ when you press a button. Files you attach pass straight through to DataBounty: the Space doesn't keep them.
137
+
138
+ ## Source
139
+
140
+ Apache-2.0. The code lives in [Databounty-io/databounty](https://github.com/Databounty-io/databounty) under
141
+ `deploy/huggingface/databounty`.
account.py CHANGED
@@ -1,518 +1,531 @@
1
- # SPDX-License-Identifier: Apache-2.0
2
- """Signing in to DataBounty from the Space, and acting as the person who signed in.
3
-
4
- The Space is an OAuth 2.1 public client of DataBounty's MCP authorization server,
5
- whose endpoints it reads from the server's own metadata document. It identifies
6
- itself with a Client ID Metadata Document that it serves (/oauth/client-metadata.json),
7
- so there is no registration step and no client secret to keep.
8
-
9
- How a sign-in finishes where it started. Opened at its own address, the Space signs in
10
- in the same tab. Inside the huggingface.co embed it can't: the embed may open windows
11
- but not leave the page, and DataBounty's sign-in pages refuse to be framed. There it
12
- opens a small sign-in window, which closes itself when done.
13
-
14
- 1. The page keeps a random secret in its own sessionStorage and starts the sign-in
15
- with only the secret's hash (/oauth/start?n=<hash>). /oauth/start refuses a start
16
- that didn't come from the Space's own page (Sec-Fetch-Site), so nobody can hand
17
- someone a link that signs *them* in for the sender to collect.
18
- 2. /oauth/start sets a short-lived, signed, httpOnly cookie holding the `state`, the
19
- PKCE verifier and the hash, then sends the browser to DataBounty. The callback
20
- accepts only a `state` matching that cookie, so it ties the round trip to the
21
- browser that started it.
22
- 3. The callback holds the new session under the hash for a few minutes
23
- (hold_sign_in). The page that started it claims it with the secret itself
24
- (claim_sign_in): nobody else has that secret, and it is never in a URL.
25
- 4. The page keeps the session handle in its own localStorage and sends it as a
26
- bearer token (webapi.py), not in a cookie: inside the embed, the Space's cookies
27
- are third-party and most browsers withhold them.
28
-
29
- Starting a sign-in keeps nothing on the server, so hitting /oauth/start repeatedly
30
- cannot crowd out real sign-ins. Tokens never reach the browser: it holds only a
31
- random session handle. The access and refresh tokens stay in this process's memory,
32
- so a restart signs everyone out; nothing is written to disk.
33
- """
34
-
35
- from __future__ import annotations
36
-
37
- import base64
38
- import hashlib
39
- import hmac
40
- import itertools
41
- import json
42
- import os
43
- import re
44
- import secrets
45
- import threading
46
- import time
47
- from dataclasses import dataclass, field
48
- from pathlib import Path
49
- from typing import Any
50
- from urllib.parse import urlencode, urlsplit
51
-
52
- import httpx
53
-
54
- DATABOUNTY_MCP_URL = os.environ.get("DATABOUNTY_MCP_URL", "https://console.databounty.io/mcp").rstrip("/")
55
- # Only for an upload URL DataBounty returns as a path (local storage); everything else goes through MCP.
56
- DATABOUNTY_API_URL = os.environ.get("DATABOUNTY_API_URL", "https://api.databounty.io").rstrip("/")
57
- SPACE_HOST = os.environ.get("SPACE_HOST", "databounty-io-databounty.hf.space")
58
- SPACE_URL = os.environ.get("SPACE_PUBLIC_URL", f"https://{SPACE_HOST}").rstrip("/")
59
-
60
- # DataBounty fetches this URL to learn who the Space is. DATABOUNTY_CLIENT_ID overrides it only for a
61
- # local run, where DataBounty refuses to fetch a localhost document and a registered client id is used.
62
- CLIENT_ID = os.environ.get("DATABOUNTY_CLIENT_ID") or f"{SPACE_URL}/oauth/client-metadata.json"
63
- REDIRECT_URI = f"{SPACE_URL}/oauth/callback"
64
- # Only what the Space's screens need: read pools and your own items, submit and
65
- # revise them, upload files for file fields, review other people's items, see the
66
- # items in a pool you requested, and set up a handle on first sign-in. Each is a
67
- # box the person can untick.
68
- SCOPES = "read contribute validate artifact sponsor account"
69
-
70
- LOGIN_COOKIE = "dbspace_login"
71
- LOGIN_TTL_SECONDS = 10 * 60
72
- SESSION_IDLE_SECONDS = 30 * 24 * 60 * 60
73
- MAX_SESSIONS = 20_000
74
- REFRESH_MARGIN_SECONDS = 60
75
- # How long a finished sign-in waits for the page that started it to claim it.
76
- CLAIM_TTL_SECONDS = 5 * 60
77
- _NONCE_HASH = re.compile(r"^[0-9a-f]{64}$")
78
- TIMEOUT = httpx.Timeout(20.0, connect=10.0)
79
- USER_AGENT = "databounty-space/1.0 (+https://huggingface.co/spaces/databounty-io/DataBounty)"
80
-
81
- # Secure cookies everywhere except a local run over plain http.
82
- COOKIE_SECURE = SPACE_URL.startswith("https://")
83
- # Signs the login cookie. A Space secret keeps sign-ins in flight valid across a
84
- # restart; without one, a restart only means starting that sign-in again.
85
- _COOKIE_KEY = (os.environ.get("SPACE_COOKIE_SECRET") or secrets.token_urlsafe(32)).encode()
86
-
87
- _client = httpx.Client(timeout=TIMEOUT, headers={"User-Agent": USER_AGENT})
88
-
89
-
90
- class SignInError(Exception):
91
- """A sign-in that cannot complete, with a message fit to show the person."""
92
-
93
-
94
- class NotSignedIn(Exception):
95
- """The session has no usable tokens left; the person has to sign in again."""
96
-
97
-
98
- class ToolError(Exception):
99
- """DataBounty refused a tool call. The message is DataBounty's own, written for people."""
100
-
101
- def __init__(self, message: str, code: str | None = None):
102
- super().__init__(message)
103
- self.code = code
104
-
105
-
106
- def client_metadata() -> dict[str, Any]:
107
- """The Client ID Metadata Document DataBounty fetches to learn who this client is."""
108
- return {
109
- "client_id": CLIENT_ID,
110
- "client_name": "DataBounty on Hugging Face",
111
- "client_uri": "https://huggingface.co/spaces/databounty-io/DataBounty",
112
- "redirect_uris": [REDIRECT_URI],
113
- "grant_types": ["authorization_code", "refresh_token"],
114
- "response_types": ["code"],
115
- "token_endpoint_auth_method": "none",
116
- "scope": SCOPES,
117
- }
118
-
119
-
120
- _metadata: dict[str, Any] | None = None
121
- _metadata_at = 0.0
122
- _metadata_lock = threading.Lock()
123
-
124
-
125
- def server_metadata() -> dict[str, Any]:
126
- """DataBounty's authorization server metadata (RFC 8414), cached for an hour."""
127
- global _metadata, _metadata_at
128
- with _metadata_lock:
129
- if _metadata and time.monotonic() - _metadata_at < 3600:
130
- return _metadata
131
- origin = "{0.scheme}://{0.netloc}".format(urlsplit(DATABOUNTY_MCP_URL))
132
- res = _client.get(f"{origin}/.well-known/oauth-authorization-server")
133
- res.raise_for_status()
134
- doc = res.json()
135
- for key in ("issuer", "authorization_endpoint", "token_endpoint"):
136
- if not isinstance(doc.get(key), str):
137
- raise SignInError("DataBounty's sign-in service is misconfigured. Try again later.")
138
- _metadata, _metadata_at = doc, time.monotonic()
139
- return doc
140
-
141
-
142
- @dataclass
143
- class Session:
144
- access_token: str
145
- refresh_token: str | None
146
- expires_at: float
147
- scope: str
148
- last_used: float = field(default_factory=time.time)
149
- me: dict[str, Any] | None = None
150
- lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
151
-
152
-
153
- _sessions: dict[str, Session] = {}
154
- _store_lock = threading.Lock()
155
-
156
-
157
- def _digest(value: str) -> str:
158
- return hashlib.sha256(value.encode()).hexdigest()
159
-
160
-
161
- def _prune(now: float) -> None:
162
- for key in [k for k, s in _sessions.items() if now - s.last_used > SESSION_IDLE_SECONDS]:
163
- _sessions.pop(key, None)
164
-
165
-
166
- def _seal(data: dict[str, Any]) -> str:
167
- # Unpadded base64url plus a hex MAC: only characters a cookie may carry unquoted, so the
168
- # value never relies on every proxy and client handling a quoted cookie correctly.
169
- payload = base64.urlsafe_b64encode(json.dumps(data, separators=(",", ":")).encode()).rstrip(b"=").decode()
170
- mac = hmac.new(_COOKIE_KEY, payload.encode(), hashlib.sha256).hexdigest()
171
- return f"{payload}.{mac}"
172
-
173
-
174
- def _unseal(value: str | None) -> dict[str, Any] | None:
175
- if not value or value.count(".") != 1:
176
- return None
177
- payload, mac = value.split(".")
178
- expected = hmac.new(_COOKIE_KEY, payload.encode(), hashlib.sha256).hexdigest()
179
- if not hmac.compare_digest(mac, expected):
180
- return None
181
- try:
182
- data = json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)))
183
- except ValueError:
184
- return None
185
- return data if isinstance(data, dict) else None
186
-
187
-
188
- def valid_nonce_hash(value: str | None) -> bool:
189
- """Whether `value` looks like the SHA-256 hex digest a page sends to /oauth/start."""
190
- return bool(value) and bool(_NONCE_HASH.match(value))
191
-
192
-
193
- def begin_sign_in(nonce_hash: str = "", popup: bool = False) -> tuple[str, str]:
194
- """A new sign-in: (the URL to send the browser to, the login cookie that binds it to this browser).
195
-
196
- `nonce_hash` is the hash of the starting page's secret, kept for claim_sign_in; `popup`
197
- says the sign-in runs in its own small window, which the callback closes when done."""
198
- meta = server_metadata()
199
- state = secrets.token_urlsafe(32)
200
- verifier = secrets.token_urlsafe(48)
201
- challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
202
- cookie = _seal({"s": state, "v": verifier, "t": int(time.time()), "n": nonce_hash, "p": bool(popup)})
203
- query = urlencode(
204
- {
205
- "response_type": "code",
206
- "client_id": CLIENT_ID,
207
- "redirect_uri": REDIRECT_URI,
208
- "scope": SCOPES,
209
- "state": state,
210
- "code_challenge": challenge,
211
- "code_challenge_method": "S256",
212
- "resource": DATABOUNTY_MCP_URL,
213
- }
214
- )
215
- return f"{meta['authorization_endpoint']}?{query}", cookie
216
-
217
-
218
- def _token_request(data: dict[str, str]) -> dict[str, Any]:
219
- meta = server_metadata()
220
- res = _client.post(meta["token_endpoint"], data={**data, "client_id": CLIENT_ID, "resource": DATABOUNTY_MCP_URL})
221
- try:
222
- body = res.json()
223
- except ValueError:
224
- body = {}
225
- if res.status_code != 200 or not isinstance(body.get("access_token"), str):
226
- raise SignInError(str(body.get("error_description") or body.get("error") or f"HTTP {res.status_code}"))
227
- return body
228
-
229
-
230
- def complete_sign_in(query: dict[str, str], login_cookie: str | None) -> str:
231
- """Finish the round trip that /oauth/start began. Returns the new session id."""
232
- started = _unseal(login_cookie)
233
- state = query.get("state") or ""
234
- if not started or not state or not hmac.compare_digest(state, str(started.get("s", ""))):
235
- raise SignInError("This sign-in was not started in this browser, or it expired. Start again from the Space.")
236
- if time.time() - float(started.get("t", 0)) > LOGIN_TTL_SECONDS:
237
- raise SignInError("This sign-in expired. Start again from the Space.")
238
- if query.get("error"):
239
- if query["error"] == "access_denied":
240
- raise SignInError("You didn't approve the Space, so nothing was connected.")
241
- raise SignInError(query.get("error_description") or "DataBounty couldn't complete the sign-in.")
242
- meta = server_metadata()
243
- # RFC 9207: the response names the server that issued it, so a code from a
244
- # different authorization server can never be redeemed here by mistake.
245
- if query.get("iss") and query["iss"] != meta["issuer"]:
246
- raise SignInError("That sign-in came back from an unexpected server, so it was not used.")
247
- code = query.get("code") or ""
248
- if not code:
249
- raise SignInError("DataBounty didn't return a sign-in code. Start again from the Space.")
250
- try:
251
- tokens = _token_request(
252
- {"grant_type": "authorization_code", "code": code, "redirect_uri": REDIRECT_URI, "code_verifier": str(started["v"])}
253
- )
254
- except SignInError as exc:
255
- raise SignInError(f"DataBounty didn't accept the sign-in ({exc}). Start again from the Space.") from exc
256
- session = Session(
257
- access_token=tokens["access_token"],
258
- refresh_token=tokens.get("refresh_token"),
259
- expires_at=time.time() + float(tokens.get("expires_in") or 3600),
260
- scope=str(tokens.get("scope") or SCOPES),
261
- )
262
- session_id = secrets.token_urlsafe(32)
263
- with _store_lock:
264
- _prune(time.time())
265
- if len(_sessions) >= MAX_SESSIONS:
266
- oldest = min(_sessions, key=lambda key: _sessions[key].last_used)
267
- _sessions.pop(oldest, None)
268
- _sessions[_digest(session_id)] = session
269
- return session_id
270
-
271
-
272
- def login_context(login_cookie: str | None) -> dict[str, Any]:
273
- """What /oauth/start recorded about this sign-in: the page's secret hash and whether it runs in a window."""
274
- started = _unseal(login_cookie) or {}
275
- return {"n": str(started.get("n") or ""), "p": bool(started.get("p"))}
276
-
277
-
278
- _pending: dict[str, tuple[str, float]] = {}
279
-
280
-
281
- def hold_sign_in(nonce_hash: str, session_id: str) -> None:
282
- """Keep a finished sign-in for the page that started it to claim."""
283
- now = time.time()
284
- with _store_lock:
285
- for key in [k for k, (_, at) in _pending.items() if now - at > CLAIM_TTL_SECONDS]:
286
- _pending.pop(key, None)
287
- _pending[nonce_hash] = (session_id, now)
288
-
289
-
290
- def sign_in_ready(nonce_hash: str | None) -> bool:
291
- """Whether a sign-in started with this hash has finished. Says nothing else, so it is safe to poll."""
292
- if not valid_nonce_hash(nonce_hash):
293
- return False
294
- with _store_lock:
295
- entry = _pending.get(nonce_hash)
296
- return bool(entry) and time.time() - entry[1] <= CLAIM_TTL_SECONDS
297
-
298
-
299
- def claim_sign_in(nonce: str | None) -> str | None:
300
- """The session a finished sign-in left for the page holding `nonce`, once. Only the page
301
- that started the sign-in has the nonce; everyone else has at most its hash."""
302
- if not nonce:
303
- return None
304
- with _store_lock:
305
- entry = _pending.pop(_digest(nonce), None)
306
- if not entry or time.time() - entry[1] > CLAIM_TTL_SECONDS:
307
- return None
308
- return entry[0]
309
-
310
-
311
- def session_for(session_id: str | None) -> Session | None:
312
- if not session_id:
313
- return None
314
- with _store_lock:
315
- session = _sessions.get(_digest(session_id))
316
- if session:
317
- session.last_used = time.time()
318
- return session
319
-
320
-
321
- def sign_out(session_id: str | None) -> None:
322
- """Forget the session here and revoke its tokens at DataBounty (best effort)."""
323
- if not session_id:
324
- return
325
- with _store_lock:
326
- session = _sessions.pop(_digest(session_id), None)
327
- if not session:
328
- return
329
- endpoint = server_metadata().get("revocation_endpoint")
330
- if not endpoint:
331
- return
332
- for token, hint in ((session.refresh_token, "refresh_token"), (session.access_token, "access_token")):
333
- if token:
334
- try:
335
- _client.post(endpoint, data={"token": token, "token_type_hint": hint, "client_id": CLIENT_ID})
336
- except httpx.HTTPError:
337
- pass
338
-
339
-
340
- def _access_token(session: Session) -> str:
341
- """A live access token, refreshed first when it is about to expire."""
342
- with session.lock:
343
- if session.expires_at - REFRESH_MARGIN_SECONDS > time.time():
344
- return session.access_token
345
- if not session.refresh_token:
346
- raise NotSignedIn()
347
- try:
348
- tokens = _token_request({"grant_type": "refresh_token", "refresh_token": session.refresh_token})
349
- except SignInError as exc:
350
- raise NotSignedIn() from exc
351
- session.access_token = tokens["access_token"]
352
- # Refresh tokens rotate: keep the new one, the old one is now spent.
353
- session.refresh_token = tokens.get("refresh_token") or session.refresh_token
354
- session.expires_at = time.time() + float(tokens.get("expires_in") or 3600)
355
- return session.access_token
356
-
357
-
358
- # --- Calling DataBounty's MCP tools as the signed-in person -------------------------------------
359
-
360
- MCP_PROTOCOL = "2026-07-28"
361
- _rpc_ids = itertools.count(1)
362
-
363
-
364
- def _rpc_body(text: str) -> dict[str, Any]:
365
- """The JSON-RPC reply, whether DataBounty answered with plain JSON or a one-event SSE stream."""
366
- trimmed = text.lstrip()
367
- if trimmed.startswith("{"):
368
- return json.loads(trimmed)
369
- for line in text.splitlines():
370
- line = line.strip()
371
- if line.startswith("data:"):
372
- return json.loads(line[len("data:"):].strip())
373
- raise ToolError("DataBounty sent a reply the Space could not read. Try again.")
374
-
375
-
376
- def _post_tool(token: str, name: str, arguments: dict[str, Any] | None) -> httpx.Response:
377
- """One tools/call to DataBounty's MCP server with this bearer token, in the stateless MCP
378
- protocol: no handshake and no server-side MCP session to expire."""
379
- payload = {
380
- "jsonrpc": "2.0",
381
- "id": next(_rpc_ids),
382
- "method": "tools/call",
383
- "params": {
384
- "name": name,
385
- "arguments": arguments or {},
386
- "_meta": {
387
- "io.modelcontextprotocol/protocolVersion": MCP_PROTOCOL,
388
- "io.modelcontextprotocol/clientCapabilities": {},
389
- "io.modelcontextprotocol/clientInfo": {"name": "databounty-hf-space", "version": "1.0.0"},
390
- },
391
- },
392
- }
393
- try:
394
- return _client.post(
395
- DATABOUNTY_MCP_URL,
396
- json=payload,
397
- headers={
398
- "Authorization": f"Bearer {token}",
399
- "Accept": "application/json, text/event-stream",
400
- "MCP-Protocol-Version": MCP_PROTOCOL,
401
- "Mcp-Method": "tools/call",
402
- "Mcp-Name": name,
403
- },
404
- )
405
- except httpx.HTTPError as exc:
406
- raise ToolError("DataBounty didn't answer in time. Try again in a moment.") from exc
407
-
408
-
409
- def _tool_result(res: httpx.Response) -> Any:
410
- """A tool's parsed result, or ToolError with DataBounty's own message. (401 is the caller's.)"""
411
- if res.status_code == 429:
412
- wait = res.headers.get("Retry-After", "a few")
413
- raise ToolError(f"DataBounty is limiting requests right now. Try again in {wait} seconds.")
414
- if res.status_code >= 400:
415
- try:
416
- detail = res.json()
417
- message = detail.get("error_description") or detail.get("message") or detail.get("error")
418
- except ValueError:
419
- message = None
420
- raise ToolError(str(message or f"DataBounty refused the request (HTTP {res.status_code})."))
421
- body = _rpc_body(res.text)
422
- if body.get("error"):
423
- raise ToolError(str(body["error"].get("message") or "DataBounty refused the request."))
424
- result = body.get("result") or {}
425
- content = result.get("content") or [{}]
426
- text = content[0].get("text", "") if isinstance(content[0], dict) else ""
427
- if result.get("isError"):
428
- raise ToolError(text or "DataBounty refused the request.")
429
- try:
430
- return json.loads(text)
431
- except ValueError:
432
- return text
433
-
434
-
435
- def call_tool(session: Session, name: str, arguments: dict[str, Any] | None = None) -> Any:
436
- """Call one DataBounty tool as this session's person. Returns the tool's parsed result."""
437
- for attempt in (1, 2):
438
- res = _post_tool(_access_token(session), name, arguments)
439
- if res.status_code == 401:
440
- if attempt == 1:
441
- # Revoked or expired early (another device signed out): refresh once and retry.
442
- with session.lock:
443
- session.expires_at = 0
444
- continue
445
- raise NotSignedIn()
446
- return _tool_result(res)
447
- raise NotSignedIn()
448
-
449
-
450
- # DataBounty's personal API keys (apps/api services/api-keys.ts KEY_PREFIX). Only strings that
451
- # look like one are ever forwarded, so a token pasted into the wrong setting goes nowhere.
452
- API_KEY_PREFIX = "db_live_sk_"
453
-
454
-
455
- def call_tool_with_key(api_key: str, name: str, arguments: dict[str, Any] | None = None) -> Any:
456
- """Call one DataBounty tool as the owner of a DataBounty API key (the agent tools in
457
- agent_tools.py). The key is used for this one request, and never stored or logged; DataBounty
458
- checks it, its scopes and its owner on every call."""
459
- if not api_key.startswith(API_KEY_PREFIX):
460
- raise NotSignedIn()
461
- res = _post_tool(api_key, name, arguments)
462
- if res.status_code == 401:
463
- raise NotSignedIn()
464
- return _tool_result(res)
465
-
466
-
467
- def upload_file(session: Session, bounty_id: str, path: str, filename: str, content_type: str) -> str:
468
- """Upload one file for a file field and wait for DataBounty's scan. Returns the artifact id
469
- that goes in the item's payload."""
470
- data = Path(path).read_bytes()
471
- slot = call_tool(
472
- session,
473
- "prepare_file_upload",
474
- {
475
- "filename": filename,
476
- "contentType": content_type,
477
- "kind": "submission_attachment",
478
- "sizeBytes": len(data),
479
- "checksumSha256": hashlib.sha256(data).hexdigest(),
480
- "bountyId": bounty_id,
481
- },
482
- )
483
- upload = slot.get("upload") or {}
484
- # A reused slot is the same pending file with a fresh URL: it still needs the bytes.
485
- url = str(upload.get("url") or "")
486
- if url.startswith("/"):
487
- url = f"{DATABOUNTY_API_URL}{url}"
488
- try:
489
- # A signed form POST: every policy field first, then the file as `file`.
490
- res = _client.post(
491
- url,
492
- data=upload.get("fields") or {},
493
- files={"file": (filename, data, content_type)},
494
- timeout=httpx.Timeout(120.0, connect=10.0),
495
- )
496
- except httpx.HTTPError as exc:
497
- raise ToolError(f"{filename} could not be uploaded. Try again.") from exc
498
- if res.status_code >= 300:
499
- raise ToolError(f"{filename} was refused by storage (HTTP {res.status_code}).")
500
- file_id = slot.get("fileId") or slot.get("artifactId")
501
- call_tool(session, "complete_file_upload", {"fileId": file_id})
502
- deadline = time.time() + 120
503
- while True:
504
- status = call_tool(session, "get_file_status", {"fileId": file_id}) or {}
505
- state = status.get("status")
506
- if state == "ready":
507
- return slot["artifactId"]
508
- if state in {"quarantined", "deleted"}:
509
- raise ToolError(f"{filename} did not pass DataBounty's file checks, so it can't be attached.")
510
- progress = status.get("scanProgress") or {}
511
- if progress.get("blocked"):
512
- raise ToolError(f"{filename} is stuck in DataBounty's file scan and needs an operator. Try again later.")
513
- # Pace by DataBounty's own estimate instead of polling blindly.
514
- wait = (status.get("ingest") or {}).get("recheckAfterSeconds")
515
- wait = min(max(float(wait), 2.0), 15.0) if isinstance(wait, (int, float)) else 3.0
516
- if time.time() + wait > deadline:
517
- raise ToolError(f"{filename} is still being checked. Submit again in a minute.")
518
- time.sleep(wait)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Signing in to DataBounty from the Space, and acting as the person who signed in.
3
+
4
+ The Space is an OAuth 2.1 public client of DataBounty's MCP authorization server,
5
+ whose endpoints it reads from the server's own metadata document. It identifies
6
+ itself with a Client ID Metadata Document that it serves (/oauth/client-metadata.json),
7
+ so there is no registration step and no client secret to keep.
8
+
9
+ How a sign-in finishes where it started. Opened at its own address, the Space signs in
10
+ in the same tab. Inside the huggingface.co embed it can't: the embed may open windows
11
+ but not leave the page, and DataBounty's sign-in pages refuse to be framed. There it
12
+ opens a small sign-in window, which closes itself when done.
13
+
14
+ 1. The page keeps a random secret in its own sessionStorage and starts the sign-in
15
+ with only the secret's hash (/oauth/start?n=<hash>). /oauth/start refuses a start
16
+ that didn't come from the Space's own page (Sec-Fetch-Site), so nobody can hand
17
+ someone a link that signs *them* in for the sender to collect.
18
+ 2. /oauth/start sets a short-lived, signed, httpOnly cookie holding the `state`, the
19
+ PKCE verifier and the hash, then sends the browser to DataBounty. The callback
20
+ accepts only a `state` matching that cookie, so it ties the round trip to the
21
+ browser that started it.
22
+ 3. The callback holds the new session under the hash for a few minutes
23
+ (hold_sign_in). The page that started it claims it with the secret itself
24
+ (claim_sign_in): nobody else has that secret, and it is never in a URL.
25
+ 4. The page keeps the session handle in its own localStorage and sends it as a
26
+ bearer token (webapi.py), not in a cookie: inside the embed, the Space's cookies
27
+ are third-party and most browsers withhold them.
28
+
29
+ Starting a sign-in keeps nothing on the server, so hitting /oauth/start repeatedly
30
+ cannot crowd out real sign-ins. Tokens never reach the browser: it holds only a
31
+ random session handle. The access and refresh tokens stay in this process's memory,
32
+ so a restart signs everyone out; nothing is written to disk.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import base64
38
+ import hashlib
39
+ import hmac
40
+ import itertools
41
+ import json
42
+ import os
43
+ import re
44
+ import secrets
45
+ import threading
46
+ import time
47
+ from dataclasses import dataclass, field
48
+ from pathlib import Path
49
+ from typing import Any
50
+ from urllib.parse import urlencode, urlsplit
51
+
52
+ import httpx
53
+
54
+ DATABOUNTY_MCP_URL = os.environ.get("DATABOUNTY_MCP_URL", "https://console.databounty.io/mcp").rstrip("/")
55
+ # Only for an upload URL DataBounty returns as a path (local storage); everything else goes through MCP.
56
+ DATABOUNTY_API_URL = os.environ.get("DATABOUNTY_API_URL", "https://api.databounty.io").rstrip("/")
57
+ SPACE_HOST = os.environ.get("SPACE_HOST", "databounty-io-databounty.hf.space")
58
+ SPACE_URL = os.environ.get("SPACE_PUBLIC_URL", f"https://{SPACE_HOST}").rstrip("/")
59
+
60
+ # DataBounty fetches this URL to learn who the Space is. DATABOUNTY_CLIENT_ID overrides it only for a
61
+ # local run, where DataBounty refuses to fetch a localhost document and a registered client id is used.
62
+ CLIENT_ID = os.environ.get("DATABOUNTY_CLIENT_ID") or f"{SPACE_URL}/oauth/client-metadata.json"
63
+ REDIRECT_URI = f"{SPACE_URL}/oauth/callback"
64
+ # Only what the Space's screens need: read pools and your own items, submit and
65
+ # revise them, upload files for file fields, review other people's items, see the
66
+ # items in a pool you requested, and set up a handle on first sign-in. Each is a
67
+ # box the person can untick.
68
+ SCOPES = "read contribute validate artifact sponsor account"
69
+
70
+ LOGIN_COOKIE = "dbspace_login"
71
+ LOGIN_TTL_SECONDS = 10 * 60
72
+ SESSION_IDLE_SECONDS = 30 * 24 * 60 * 60
73
+ MAX_SESSIONS = 20_000
74
+ REFRESH_MARGIN_SECONDS = 60
75
+ # How long a finished sign-in waits for the page that started it to claim it.
76
+ CLAIM_TTL_SECONDS = 5 * 60
77
+ _NONCE_HASH = re.compile(r"^[0-9a-f]{64}$")
78
+ TIMEOUT = httpx.Timeout(20.0, connect=10.0)
79
+ USER_AGENT = "databounty-space/1.0 (+https://huggingface.co/spaces/databounty-io/DataBounty)"
80
+
81
+ # Secure cookies everywhere except a local run over plain http.
82
+ COOKIE_SECURE = SPACE_URL.startswith("https://")
83
+ # Signs the login cookie. A Space secret keeps sign-ins in flight valid across a
84
+ # restart; without one, a restart only means starting that sign-in again.
85
+ _COOKIE_KEY = (os.environ.get("SPACE_COOKIE_SECRET") or secrets.token_urlsafe(32)).encode()
86
+
87
+ _client = httpx.Client(timeout=TIMEOUT, headers={"User-Agent": USER_AGENT})
88
+
89
+
90
+ class SignInError(Exception):
91
+ """A sign-in that cannot complete, with a message fit to show the person."""
92
+
93
+
94
+ class NotSignedIn(Exception):
95
+ """The session has no usable tokens left; the person has to sign in again."""
96
+
97
+
98
+ class ToolError(Exception):
99
+ """DataBounty refused a tool call. The message is DataBounty's own, written for people."""
100
+
101
+ def __init__(self, message: str, code: str | None = None):
102
+ super().__init__(message)
103
+ self.code = code
104
+
105
+
106
+ def client_metadata() -> dict[str, Any]:
107
+ """The Client ID Metadata Document DataBounty fetches to learn who this client is."""
108
+ return {
109
+ "client_id": CLIENT_ID,
110
+ "client_name": "DataBounty on Hugging Face",
111
+ "client_uri": "https://huggingface.co/spaces/databounty-io/DataBounty",
112
+ "redirect_uris": [REDIRECT_URI],
113
+ "grant_types": ["authorization_code", "refresh_token"],
114
+ "response_types": ["code"],
115
+ "token_endpoint_auth_method": "none",
116
+ "scope": SCOPES,
117
+ }
118
+
119
+
120
+ _metadata: dict[str, Any] | None = None
121
+ _metadata_at = 0.0
122
+ _metadata_lock = threading.Lock()
123
+
124
+
125
+ def server_metadata() -> dict[str, Any]:
126
+ """DataBounty's authorization server metadata (RFC 8414), cached for an hour."""
127
+ global _metadata, _metadata_at
128
+ with _metadata_lock:
129
+ if _metadata and time.monotonic() - _metadata_at < 3600:
130
+ return _metadata
131
+ origin = "{0.scheme}://{0.netloc}".format(urlsplit(DATABOUNTY_MCP_URL))
132
+ res = _client.get(f"{origin}/.well-known/oauth-authorization-server")
133
+ res.raise_for_status()
134
+ doc = res.json()
135
+ for key in ("issuer", "authorization_endpoint", "token_endpoint"):
136
+ if not isinstance(doc.get(key), str):
137
+ raise SignInError("DataBounty's sign-in service is misconfigured. Try again later.")
138
+ _metadata, _metadata_at = doc, time.monotonic()
139
+ return doc
140
+
141
+
142
+ @dataclass
143
+ class Session:
144
+ access_token: str
145
+ refresh_token: str | None
146
+ expires_at: float
147
+ scope: str
148
+ last_used: float = field(default_factory=time.time)
149
+ me: dict[str, Any] | None = None
150
+ lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
151
+
152
+
153
+ _sessions: dict[str, Session] = {}
154
+ _store_lock = threading.Lock()
155
+
156
+
157
+ def _digest(value: str) -> str:
158
+ return hashlib.sha256(value.encode()).hexdigest()
159
+
160
+
161
+ def _prune(now: float) -> None:
162
+ for key in [k for k, s in _sessions.items() if now - s.last_used > SESSION_IDLE_SECONDS]:
163
+ _sessions.pop(key, None)
164
+
165
+
166
+ def _seal(data: dict[str, Any]) -> str:
167
+ # Unpadded base64url plus a hex MAC: only characters a cookie may carry unquoted, so the
168
+ # value never relies on every proxy and client handling a quoted cookie correctly.
169
+ payload = base64.urlsafe_b64encode(json.dumps(data, separators=(",", ":")).encode()).rstrip(b"=").decode()
170
+ mac = hmac.new(_COOKIE_KEY, payload.encode(), hashlib.sha256).hexdigest()
171
+ return f"{payload}.{mac}"
172
+
173
+
174
+ def _unseal(value: str | None) -> dict[str, Any] | None:
175
+ if not value or value.count(".") != 1:
176
+ return None
177
+ payload, mac = value.split(".")
178
+ expected = hmac.new(_COOKIE_KEY, payload.encode(), hashlib.sha256).hexdigest()
179
+ if not hmac.compare_digest(mac, expected):
180
+ return None
181
+ try:
182
+ data = json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)))
183
+ except ValueError:
184
+ return None
185
+ return data if isinstance(data, dict) else None
186
+
187
+
188
+ def valid_nonce_hash(value: str | None) -> bool:
189
+ """Whether `value` looks like the SHA-256 hex digest a page sends to /oauth/start."""
190
+ return bool(value) and bool(_NONCE_HASH.match(value))
191
+
192
+
193
+ def begin_sign_in(nonce_hash: str = "", popup: bool = False) -> tuple[str, str]:
194
+ """A new sign-in: (the URL to send the browser to, the login cookie that binds it to this browser).
195
+
196
+ `nonce_hash` is the hash of the starting page's secret, kept for claim_sign_in; `popup`
197
+ says the sign-in runs in its own small window, which the callback closes when done."""
198
+ meta = server_metadata()
199
+ state = secrets.token_urlsafe(32)
200
+ verifier = secrets.token_urlsafe(48)
201
+ challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
202
+ cookie = _seal({"s": state, "v": verifier, "t": int(time.time()), "n": nonce_hash, "p": bool(popup)})
203
+ query = urlencode(
204
+ {
205
+ "response_type": "code",
206
+ "client_id": CLIENT_ID,
207
+ "redirect_uri": REDIRECT_URI,
208
+ "scope": SCOPES,
209
+ "state": state,
210
+ "code_challenge": challenge,
211
+ "code_challenge_method": "S256",
212
+ "resource": DATABOUNTY_MCP_URL,
213
+ }
214
+ )
215
+ return f"{meta['authorization_endpoint']}?{query}", cookie
216
+
217
+
218
+ def _token_request(data: dict[str, str]) -> dict[str, Any]:
219
+ meta = server_metadata()
220
+ res = _client.post(meta["token_endpoint"], data={**data, "client_id": CLIENT_ID, "resource": DATABOUNTY_MCP_URL})
221
+ try:
222
+ body = res.json()
223
+ except ValueError:
224
+ body = {}
225
+ if res.status_code != 200 or not isinstance(body.get("access_token"), str):
226
+ raise SignInError(str(body.get("error_description") or body.get("error") or f"HTTP {res.status_code}"))
227
+ return body
228
+
229
+
230
+ def complete_sign_in(query: dict[str, str], login_cookie: str | None) -> str:
231
+ """Finish the round trip that /oauth/start began. Returns the new session id."""
232
+ started = _unseal(login_cookie)
233
+ state = query.get("state") or ""
234
+ if not started or not state or not hmac.compare_digest(state, str(started.get("s", ""))):
235
+ raise SignInError("This sign-in was not started in this browser, or it expired. Start again from the Space.")
236
+ if time.time() - float(started.get("t", 0)) > LOGIN_TTL_SECONDS:
237
+ raise SignInError("This sign-in expired. Start again from the Space.")
238
+ if query.get("error"):
239
+ if query["error"] == "access_denied":
240
+ raise SignInError("You didn't approve the Space, so nothing was connected.")
241
+ raise SignInError(query.get("error_description") or "DataBounty couldn't complete the sign-in.")
242
+ meta = server_metadata()
243
+ # RFC 9207: the response names the server that issued it, so a code from a
244
+ # different authorization server can never be redeemed here by mistake.
245
+ if query.get("iss") and query["iss"] != meta["issuer"]:
246
+ raise SignInError("That sign-in came back from an unexpected server, so it was not used.")
247
+ code = query.get("code") or ""
248
+ if not code:
249
+ raise SignInError("DataBounty didn't return a sign-in code. Start again from the Space.")
250
+ try:
251
+ tokens = _token_request(
252
+ {"grant_type": "authorization_code", "code": code, "redirect_uri": REDIRECT_URI, "code_verifier": str(started["v"])}
253
+ )
254
+ except SignInError as exc:
255
+ raise SignInError(f"DataBounty didn't accept the sign-in ({exc}). Start again from the Space.") from exc
256
+ session = Session(
257
+ access_token=tokens["access_token"],
258
+ refresh_token=tokens.get("refresh_token"),
259
+ expires_at=time.time() + float(tokens.get("expires_in") or 3600),
260
+ scope=str(tokens.get("scope") or SCOPES),
261
+ )
262
+ session_id = secrets.token_urlsafe(32)
263
+ with _store_lock:
264
+ _prune(time.time())
265
+ if len(_sessions) >= MAX_SESSIONS:
266
+ oldest = min(_sessions, key=lambda key: _sessions[key].last_used)
267
+ _sessions.pop(oldest, None)
268
+ _sessions[_digest(session_id)] = session
269
+ return session_id
270
+
271
+
272
+ def login_context(login_cookie: str | None) -> dict[str, Any]:
273
+ """What /oauth/start recorded about this sign-in: the page's secret hash and whether it runs in a window."""
274
+ started = _unseal(login_cookie) or {}
275
+ return {"n": str(started.get("n") or ""), "p": bool(started.get("p"))}
276
+
277
+
278
+ _pending: dict[str, tuple[str, float]] = {}
279
+
280
+
281
+ def hold_sign_in(nonce_hash: str, session_id: str) -> None:
282
+ """Keep a finished sign-in for the page that started it to claim."""
283
+ now = time.time()
284
+ with _store_lock:
285
+ for key in [k for k, (_, at) in _pending.items() if now - at > CLAIM_TTL_SECONDS]:
286
+ _pending.pop(key, None)
287
+ _pending[nonce_hash] = (session_id, now)
288
+
289
+
290
+ def sign_in_ready(nonce_hash: str | None) -> bool:
291
+ """Whether a sign-in started with this hash has finished. Says nothing else, so it is safe to poll."""
292
+ if not valid_nonce_hash(nonce_hash):
293
+ return False
294
+ with _store_lock:
295
+ entry = _pending.get(nonce_hash)
296
+ return bool(entry) and time.time() - entry[1] <= CLAIM_TTL_SECONDS
297
+
298
+
299
+ def claim_sign_in(nonce: str | None) -> str | None:
300
+ """The session a finished sign-in left for the page holding `nonce`, once. Only the page
301
+ that started the sign-in has the nonce; everyone else has at most its hash."""
302
+ if not nonce:
303
+ return None
304
+ with _store_lock:
305
+ entry = _pending.pop(_digest(nonce), None)
306
+ if not entry or time.time() - entry[1] > CLAIM_TTL_SECONDS:
307
+ return None
308
+ return entry[0]
309
+
310
+
311
+ def session_for(session_id: str | None) -> Session | None:
312
+ if not session_id:
313
+ return None
314
+ with _store_lock:
315
+ session = _sessions.get(_digest(session_id))
316
+ if session:
317
+ session.last_used = time.time()
318
+ return session
319
+
320
+
321
+ def sign_out(session_id: str | None) -> None:
322
+ """Forget the session here and revoke its tokens at DataBounty (best effort)."""
323
+ if not session_id:
324
+ return
325
+ with _store_lock:
326
+ session = _sessions.pop(_digest(session_id), None)
327
+ if not session:
328
+ return
329
+ endpoint = server_metadata().get("revocation_endpoint")
330
+ if not endpoint:
331
+ return
332
+ for token, hint in ((session.refresh_token, "refresh_token"), (session.access_token, "access_token")):
333
+ if token:
334
+ try:
335
+ _client.post(endpoint, data={"token": token, "token_type_hint": hint, "client_id": CLIENT_ID})
336
+ except httpx.HTTPError:
337
+ pass
338
+
339
+
340
+ def _access_token(session: Session) -> str:
341
+ """A live access token, refreshed first when it is about to expire."""
342
+ with session.lock:
343
+ if session.expires_at - REFRESH_MARGIN_SECONDS > time.time():
344
+ return session.access_token
345
+ if not session.refresh_token:
346
+ raise NotSignedIn()
347
+ try:
348
+ tokens = _token_request({"grant_type": "refresh_token", "refresh_token": session.refresh_token})
349
+ except SignInError as exc:
350
+ raise NotSignedIn() from exc
351
+ session.access_token = tokens["access_token"]
352
+ # Refresh tokens rotate: keep the new one, the old one is now spent.
353
+ session.refresh_token = tokens.get("refresh_token") or session.refresh_token
354
+ session.expires_at = time.time() + float(tokens.get("expires_in") or 3600)
355
+ return session.access_token
356
+
357
+
358
+ # --- Calling DataBounty's MCP tools as the signed-in person -------------------------------------
359
+
360
+ MCP_PROTOCOL = "2026-07-28"
361
+ _rpc_ids = itertools.count(1)
362
+
363
+
364
+ def _rpc_body(text: str) -> dict[str, Any]:
365
+ """The JSON-RPC reply, whether DataBounty answered with plain JSON or a one-event SSE stream."""
366
+ trimmed = text.lstrip()
367
+ if trimmed.startswith("{"):
368
+ return json.loads(trimmed)
369
+ for line in text.splitlines():
370
+ line = line.strip()
371
+ if line.startswith("data:"):
372
+ return json.loads(line[len("data:"):].strip())
373
+ raise ToolError("DataBounty sent a reply the Space could not read. Try again.")
374
+
375
+
376
+ def _post_tool(token: str, name: str, arguments: dict[str, Any] | None) -> httpx.Response:
377
+ """One tools/call to DataBounty's MCP server with this bearer token, in the stateless MCP
378
+ protocol: no handshake and no server-side MCP session to expire."""
379
+ payload = {
380
+ "jsonrpc": "2.0",
381
+ "id": next(_rpc_ids),
382
+ "method": "tools/call",
383
+ "params": {
384
+ "name": name,
385
+ "arguments": arguments or {},
386
+ "_meta": {
387
+ "io.modelcontextprotocol/protocolVersion": MCP_PROTOCOL,
388
+ "io.modelcontextprotocol/clientCapabilities": {},
389
+ "io.modelcontextprotocol/clientInfo": {"name": "databounty-hf-space", "version": "1.0.0"},
390
+ },
391
+ },
392
+ }
393
+ try:
394
+ return _client.post(
395
+ DATABOUNTY_MCP_URL,
396
+ json=payload,
397
+ headers={
398
+ "Authorization": f"Bearer {token}",
399
+ "Accept": "application/json, text/event-stream",
400
+ "MCP-Protocol-Version": MCP_PROTOCOL,
401
+ "Mcp-Method": "tools/call",
402
+ "Mcp-Name": name,
403
+ },
404
+ )
405
+ except httpx.HTTPError as exc:
406
+ raise ToolError("DataBounty didn't answer in time. Try again in a moment.") from exc
407
+
408
+
409
+ def _tool_result(res: httpx.Response) -> Any:
410
+ """A tool's parsed result, or ToolError with DataBounty's own message. (401 is the caller's.)"""
411
+ if res.status_code == 429:
412
+ wait = res.headers.get("Retry-After", "a few")
413
+ raise ToolError(f"DataBounty is limiting requests right now. Try again in {wait} seconds.")
414
+ if res.status_code >= 400:
415
+ try:
416
+ detail = res.json()
417
+ message = detail.get("error_description") or detail.get("message") or detail.get("error")
418
+ except ValueError:
419
+ message = None
420
+ raise ToolError(str(message or f"DataBounty refused the request (HTTP {res.status_code})."))
421
+ body = _rpc_body(res.text)
422
+ if body.get("error"):
423
+ raise ToolError(str(body["error"].get("message") or "DataBounty refused the request."))
424
+ result = body.get("result") or {}
425
+ content = result.get("content") or [{}]
426
+ text = content[0].get("text", "") if isinstance(content[0], dict) else ""
427
+ if result.get("isError"):
428
+ raise ToolError(text or "DataBounty refused the request.")
429
+ try:
430
+ return json.loads(text)
431
+ except ValueError:
432
+ return text
433
+
434
+
435
+ def call_tool(session: Session, name: str, arguments: dict[str, Any] | None = None) -> Any:
436
+ """Call one DataBounty tool as this session's person. Returns the tool's parsed result."""
437
+ for attempt in (1, 2):
438
+ res = _post_tool(_access_token(session), name, arguments)
439
+ if res.status_code == 401:
440
+ if attempt == 1:
441
+ # Revoked or expired early (another device signed out): refresh once and retry.
442
+ with session.lock:
443
+ session.expires_at = 0
444
+ continue
445
+ raise NotSignedIn()
446
+ return _tool_result(res)
447
+ raise NotSignedIn()
448
+
449
+
450
+ # DataBounty's personal API keys (apps/api services/api-keys.ts KEY_PREFIX). Only strings that
451
+ # look like one are ever forwarded, so a token pasted into the wrong setting goes nowhere.
452
+ API_KEY_PREFIX = "db_live_sk_"
453
+
454
+
455
+ def call_tool_with_key(api_key: str, name: str, arguments: dict[str, Any] | None = None) -> Any:
456
+ """Call one DataBounty tool as the owner of a DataBounty API key (the agent tools in
457
+ agent_tools.py). The key is used for this one request, and never stored or logged; DataBounty
458
+ checks it, its scopes and its owner on every call."""
459
+ if not api_key.startswith(API_KEY_PREFIX):
460
+ raise NotSignedIn()
461
+ res = _post_tool(api_key, name, arguments)
462
+ if res.status_code == 401:
463
+ raise NotSignedIn()
464
+ return _tool_result(res)
465
+
466
+
467
+ def upload_file(session: Session, bounty_id: str, path: str, filename: str, content_type: str) -> str:
468
+ """Upload one file for a file field and wait for DataBounty's scan. Returns the artifact id
469
+ that goes in the item's payload."""
470
+ file_id = _upload(session, {"kind": "submission_attachment", "bountyId": bounty_id}, path, filename, content_type)
471
+ deadline = time.time() + 120
472
+ while True:
473
+ status = call_tool(session, "get_file_status", {"fileId": file_id}) or {}
474
+ state = status.get("status")
475
+ if state == "ready":
476
+ return file_id
477
+ if state in {"quarantined", "deleted"}:
478
+ raise ToolError(f"{filename} did not pass DataBounty's file checks, so it can't be attached.")
479
+ progress = status.get("scanProgress") or {}
480
+ if progress.get("blocked"):
481
+ raise ToolError(f"{filename} is stuck in DataBounty's file scan and needs an operator. Try again later.")
482
+ # Pace by DataBounty's own estimate instead of polling blindly.
483
+ wait = (status.get("ingest") or {}).get("recheckAfterSeconds")
484
+ wait = min(max(float(wait), 2.0), 15.0) if isinstance(wait, (int, float)) else 3.0
485
+ if time.time() + wait > deadline:
486
+ raise ToolError(f"{filename} is still being checked. Submit again in a minute.")
487
+ time.sleep(wait)
488
+
489
+
490
+ def upload_sample(session: Session, request_id: str, path: str, filename: str, content_type: str) -> str:
491
+ """Attach one reference sample (an example item) to the person's own dataset request. DataBounty
492
+ scans it in the background and an admin reviews it with the request, so this doesn't wait for
493
+ the scan. Returns its file id."""
494
+ return _upload(session, {"kind": "sponsor_reference", "datasetRequestId": request_id}, path, filename, content_type)
495
+
496
+
497
+ def _upload(session: Session, target: dict[str, Any], path: str, filename: str, content_type: str) -> str:
498
+ """Upload one file to DataBounty for `target` (its kind and what it belongs to) and complete it.
499
+ Returns the file id; DataBounty starts its scan."""
500
+ data = Path(path).read_bytes()
501
+ slot = call_tool(
502
+ session,
503
+ "prepare_file_upload",
504
+ {
505
+ "filename": filename,
506
+ "contentType": content_type,
507
+ "sizeBytes": len(data),
508
+ "checksumSha256": hashlib.sha256(data).hexdigest(),
509
+ **target,
510
+ },
511
+ )
512
+ upload = slot.get("upload") or {}
513
+ # A reused slot is the same pending file with a fresh URL: it still needs the bytes.
514
+ url = str(upload.get("url") or "")
515
+ if url.startswith("/"):
516
+ url = f"{DATABOUNTY_API_URL}{url}"
517
+ try:
518
+ # A signed form POST: every policy field first, then the file as `file`.
519
+ res = _client.post(
520
+ url,
521
+ data=upload.get("fields") or {},
522
+ files={"file": (filename, data, content_type)},
523
+ timeout=httpx.Timeout(120.0, connect=10.0),
524
+ )
525
+ except httpx.HTTPError as exc:
526
+ raise ToolError(f"{filename} could not be uploaded. Try again.") from exc
527
+ if res.status_code >= 300:
528
+ raise ToolError(f"{filename} was refused by storage (HTTP {res.status_code}).")
529
+ file_id = str(slot.get("fileId") or slot.get("artifactId") or "")
530
+ call_tool(session, "complete_file_upload", {"fileId": file_id})
531
+ return file_id
databounty.py CHANGED
@@ -1,426 +1,442 @@
1
- # SPDX-License-Identifier: Apache-2.0
2
- """Read-only client for DataBounty's public API and the Hugging Face Hub.
3
-
4
- Everything here reads public data: the same unauthenticated endpoints that
5
- databounty.io renders from. Each response is kept in a small in-process cache
6
- for CACHE_TTL_SECONDS, so a burst of visitors, or an agent calling the MCP tools
7
- in a loop, never turns into a burst of upstream requests. Errors are never
8
- cached, so a recovered upstream is picked up on the next call.
9
-
10
- Profiles are read only from /v1/profiles/handle/:handle, which answers 404 for a
11
- private profile and leaves out every field the member chose to hide. Nothing
12
- here calls /v1/community/members/:handle, which applies neither rule.
13
- """
14
-
15
- from __future__ import annotations
16
-
17
- import os
18
- import re
19
- import threading
20
- import time
21
- from collections import OrderedDict
22
- from collections.abc import Callable
23
- from typing import Any
24
- from urllib.parse import quote
25
-
26
- import httpx
27
- from huggingface_hub import HfApi
28
-
29
- API_URL = os.environ.get("DATABOUNTY_API_URL", "https://api.databounty.io").rstrip("/")
30
- SITE_URL = os.environ.get("DATABOUNTY_SITE_URL", "https://databounty.io").rstrip("/")
31
- APP_URL = os.environ.get("DATABOUNTY_APP_URL", "https://console.databounty.io").rstrip("/")
32
- HF_ORG = os.environ.get("DATABOUNTY_HF_ORG", "databounty-io")
33
- CACHE_TTL_SECONDS = float(os.environ.get("CACHE_TTL_SECONDS", "120"))
34
-
35
- # DataBounty's own MCP server. The dashboard origin proxies /mcp to the API, and
36
- # the API binds every OAuth credential to that origin, so this is the URL
37
- # clients must use (not API_URL).
38
- MCP_URL = f"{APP_URL}/mcp"
39
-
40
- DOMAINS = ("coding", "legal", "healthcare", "finance", "science")
41
- DIFFICULTIES = ("beginner", "intermediate", "advanced")
42
- TIERS = ("dharma", "bodhi", "moksha", "nirvana")
43
-
44
- # Same rule the dashboard applies when a member claims a handle.
45
- _HANDLE = re.compile(r"^[a-z0-9-]{3,20}$")
46
- _POOL_ID = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
47
- _PROFILE_URL = re.compile(r"^(?:https?://)?(?:www\.)?databounty\.io/+@?([^/?#]+)/*$", re.IGNORECASE)
48
- _BADGE_STEP = re.compile(r"^(?P<ladder>.+)_(?P<value>\d+)$")
49
-
50
- _PAGE_SIZE = {"/v1/community/catalog": 100, "/v1/community/pools": 50}
51
- _MAX_PAGES = 5
52
- _CACHE_MAX_ENTRIES = 256
53
-
54
-
55
- class UpstreamError(RuntimeError):
56
- """DataBounty's API could not be reached, or answered with an error."""
57
-
58
-
59
- _http = httpx.Client(
60
- base_url=API_URL,
61
- timeout=httpx.Timeout(15.0, connect=5.0),
62
- headers={
63
- "Accept": "application/json",
64
- "User-Agent": "databounty-space/1.0 (+https://huggingface.co/spaces/databounty-io/DataBounty)",
65
- },
66
- )
67
- _hub = HfApi()
68
- _cache: OrderedDict[tuple, tuple[float, Any]] = OrderedDict()
69
- _lock = threading.Lock()
70
-
71
-
72
- def _cached(key: tuple, load: Callable[[], Any]) -> Any:
73
- now = time.monotonic()
74
- with _lock:
75
- hit = _cache.get(key)
76
- if hit is not None and now - hit[0] < CACHE_TTL_SECONDS:
77
- _cache.move_to_end(key)
78
- return hit[1]
79
- value = load() # raises on failure, so errors never reach the cache
80
- with _lock:
81
- _cache[key] = (now, value)
82
- _cache.move_to_end(key)
83
- # Keys include free-text search terms, so bound the cache rather than
84
- # let a stream of unique queries grow it forever.
85
- while len(_cache) > _CACHE_MAX_ENTRIES:
86
- _cache.popitem(last=False)
87
- return value
88
-
89
-
90
- def _get(path: str, params: dict[str, Any] | None = None, *, not_found_ok: bool = False) -> Any:
91
- query = {k: v for k, v in (params or {}).items() if v not in (None, "")}
92
-
93
- def load() -> Any:
94
- try:
95
- resp = _http.get(path, params=query)
96
- except httpx.HTTPError as exc:
97
- raise UpstreamError(f"DataBounty's API could not be reached ({type(exc).__name__}).") from exc
98
- if resp.status_code == 404 and not_found_ok:
99
- return None
100
- if resp.status_code >= 400:
101
- raise UpstreamError(f"DataBounty's API answered HTTP {resp.status_code} for {path}.")
102
- try:
103
- return resp.json()
104
- except ValueError as exc:
105
- raise UpstreamError(f"DataBounty's API returned an unreadable response for {path}.") from exc
106
-
107
- return _cached(("api", path, tuple(sorted(query.items()))), load)
108
-
109
-
110
- def _all_pages(path: str, key: str) -> list[dict[str, Any]]:
111
- rows: list[dict[str, Any]] = []
112
- cursor = None
113
- for _ in range(_MAX_PAGES):
114
- page = _get(path, {"limit": _PAGE_SIZE[path], "cursor": cursor}) or {}
115
- rows.extend(page.get(key) or [])
116
- cursor = page.get("nextCursor")
117
- if not cursor:
118
- break
119
- return rows
120
-
121
-
122
- def _int(value: Any) -> int:
123
- # Counts arrive as numbers or as numeric strings (targetItems is a BigInt
124
- # serialised as text), so accept both.
125
- try:
126
- return int(value)
127
- except (TypeError, ValueError):
128
- return 0
129
-
130
-
131
- def _pct(part: int, whole: int) -> float | None:
132
- return round(100 * part / whole, 1) if whole else None
133
-
134
-
135
- def _date(value: Any) -> str | None:
136
- return value[:10] if isinstance(value, str) and len(value) >= 10 else None
137
-
138
-
139
- # --- Programme totals and karma tiers -----------------------------------------
140
-
141
-
142
- def stats() -> dict[str, Any]:
143
- """Programme totals, the karma tier ladder and the top of the leaderboard."""
144
- return _get("/v1/community/stats") or {}
145
-
146
-
147
- def tiers() -> list[dict[str, Any]]:
148
- """The live karma tier ladder, lowest threshold first."""
149
- return sorted(stats().get("tiers") or [], key=lambda t: _int(t.get("minKarma")))
150
-
151
-
152
- def tier_for(karma: int, ladder: list[dict[str, Any]]) -> dict[str, Any] | None:
153
- current = None
154
- for tier in ladder:
155
- if karma >= _int(tier.get("minKarma")):
156
- current = tier
157
- return current
158
-
159
-
160
- # --- Pools and datasets ---------------------------------------------------------
161
-
162
-
163
- def _programme(row: dict[str, Any]) -> dict[str, Any]:
164
- dataset_type = row.get("datasetType") or {}
165
- pricing = row.get("karmaPricing") or {}
166
- target = _int(row.get("targetItems"))
167
- accepted = _int(row.get("acceptedItems"))
168
- cleared = _int(row.get("clearedItems"))
169
- pool_id = str(row.get("id") or "")
170
- return {
171
- "id": pool_id,
172
- "title": row.get("title") or "",
173
- "status": row.get("status") or "",
174
- "dataset_type": dataset_type.get("name") or "",
175
- "domain": dataset_type.get("domain") or "",
176
- "category": row.get("datasetCategory") or "",
177
- "language": row.get("language") or "",
178
- "difficulty": row.get("difficulty") or "",
179
- "trust_tier": dataset_type.get("trustTier") or "",
180
- "target_items": target,
181
- "accepted_items": accepted,
182
- # Cleared items passed the automated checks and hold a slot in the pool;
183
- # the ones not yet accepted are waiting on a human validator.
184
- "in_review_items": max(cleared - accepted, 0),
185
- "progress_pct": _pct(accepted, target),
186
- "karma_per_accepted_item": pricing.get("contributorPerItem"),
187
- "validator_karma_per_audited_item": pricing.get("validatorPerAuditedItem"),
188
- "audit_coverage_pct": row.get("auditCoveragePct"),
189
- "license": row.get("communityLicense") or "",
190
- "page_url": f"{SITE_URL}/pools/{pool_id}/",
191
- "contribute_url": f"{APP_URL}/contributor/pool/{pool_id}/",
192
- }
193
-
194
-
195
- def _catalog() -> list[dict[str, Any]]:
196
- return _cached(("catalog",), lambda: _all_pages("/v1/community/catalog", "bounties"))
197
-
198
-
199
- def open_pools(query: str = "", domain: str = "", difficulty: str = "") -> list[dict[str, Any]]:
200
- """Pools currently open for contribution, optionally filtered.
201
-
202
- "Open" is whatever the platform's own open-pools endpoint lists; the catalog
203
- supplies the descriptive fields that endpoint leaves out.
204
- """
205
- open_ids = {row.get("id") for row in _all_pages("/v1/community/pools", "pools")}
206
- needle = query.strip().lower()
207
- pools = []
208
- for row in _catalog():
209
- if row.get("id") not in open_ids:
210
- continue
211
- pool = _programme(row)
212
- if domain and pool["domain"] != domain:
213
- continue
214
- if difficulty and pool["difficulty"] != difficulty:
215
- continue
216
- haystack = " ".join((pool["title"], pool["dataset_type"], pool["language"], row.get("description") or ""))
217
- if needle and needle not in haystack.lower():
218
- continue
219
- pools.append(pool)
220
- return pools
221
-
222
-
223
- def pool_details(pool_id: str) -> dict[str, Any] | None:
224
- """One pool's full public record, or None when the id is unknown."""
225
- pool_id = (pool_id or "").strip()
226
- if not _POOL_ID.match(pool_id):
227
- raise ValueError("That doesn't look like a DataBounty pool id.")
228
- row = (_get(f"/v1/community/catalog/{quote(pool_id, safe='')}", not_found_ok=True) or {}).get("bounty")
229
- if not row:
230
- return None
231
- pool = _programme(row)
232
- dataset_type = row.get("datasetType") or {}
233
- pool.update(
234
- description=row.get("description") or "",
235
- contributors=row.get("contributorCount"),
236
- validators=row.get("validatorCount"),
237
- duplicate_rate=row.get("duplicateRate"),
238
- created_at=_date(row.get("createdAt")),
239
- fields=[
240
- {
241
- "key": field.get("key"),
242
- "label": field.get("label") or field.get("key"),
243
- "role": field.get("role"),
244
- "required": bool(field.get("required")),
245
- "help": field.get("help") or "",
246
- }
247
- for field in dataset_type.get("fields") or []
248
- ],
249
- example_items=[
250
- sample["fields"]
251
- for sample in dataset_type.get("sampleAssets") or []
252
- if isinstance(sample, dict) and isinstance(sample.get("fields"), dict) and sample["fields"]
253
- ][:3],
254
- hugging_face_dataset=row.get("huggingFaceDataset"),
255
- )
256
- return pool
257
-
258
-
259
- def _hub_datasets() -> dict[str, dict[str, Any]]:
260
- def load() -> dict[str, dict[str, Any]]:
261
- return {
262
- info.id: {"downloads": info.downloads, "likes": info.likes}
263
- for info in _hub.list_datasets(author=HF_ORG, full=True)
264
- }
265
-
266
- try:
267
- return _cached(("hub", HF_ORG), load)
268
- except Exception:
269
- # Download counts are decoration; the catalog is the source of truth,
270
- # so a Hub hiccup must not take the datasets list down with it.
271
- return {}
272
-
273
-
274
- def published_datasets() -> list[dict[str, Any]]:
275
- """Datasets DataBounty has published, newest first, with Hub download counts."""
276
- hub = _hub_datasets()
277
- datasets = []
278
- for row in _catalog():
279
- if row.get("publicationStatus") != "published":
280
- continue
281
- pool = _programme(row)
282
- targets = (row.get("publication") or {}).get("targets") or []
283
-
284
- def published_url(target: str) -> str | None:
285
- return next(
286
- (t.get("url") for t in targets if t.get("target") == target and t.get("state") == "published" and t.get("url")),
287
- None,
288
- )
289
-
290
- repo_id = row.get("huggingFaceDataset") or None
291
- counts = hub.get(repo_id or "", {})
292
- datasets.append(
293
- {
294
- "title": pool["title"],
295
- "dataset_type": pool["dataset_type"],
296
- "items": pool["accepted_items"],
297
- "license": pool["license"],
298
- "difficulty": pool["difficulty"],
299
- "published_on": _date(row.get("deliveredAt")),
300
- "hugging_face_id": repo_id,
301
- "hugging_face_url": published_url("huggingface") or (f"https://huggingface.co/datasets/{repo_id}" if repo_id else None),
302
- "github_url": published_url("github"),
303
- "downloads": counts.get("downloads"),
304
- "likes": counts.get("likes"),
305
- "page_url": pool["page_url"],
306
- }
307
- )
308
- datasets.sort(key=lambda d: d["published_on"] or "", reverse=True)
309
- return datasets
310
-
311
-
312
- # --- People -----------------------------------------------------------------------
313
-
314
-
315
- def leaderboard(limit: int = 50, tier: str = "", query: str = "") -> list[dict[str, Any]]:
316
- """The all-time karma leaderboard.
317
-
318
- The API already leaves out members who hide their karma, so this never
319
- shows more than databounty.io's own leaderboard does.
320
- """
321
- params = {
322
- "limit": max(1, min(_int(limit) or 50, 100)),
323
- "tier": tier if tier in TIERS else None,
324
- "q": query.strip()[:60] or None,
325
- }
326
- rows = (_get("/v1/community/leaderboard", params) or {}).get("leaderboard") or []
327
- ladder = tiers()
328
- board = []
329
- for row in rows:
330
- karma = _int(row.get("karma"))
331
- handle = row.get("handle")
332
- board.append(
333
- {
334
- "rank": row.get("rank"),
335
- "handle": handle,
336
- "display_name": row.get("displayName") or handle,
337
- "karma": karma,
338
- "accepted_items": row.get("acceptedItems"),
339
- "tier": (tier_for(karma, ladder) or {}).get("label"),
340
- "profile_url": f"{SITE_URL}/{handle}/" if handle else None,
341
- }
342
- )
343
- return board
344
-
345
-
346
- def normalize_handle(handle: str) -> str:
347
- """Accept "starlord", "@starlord" or a pasted databounty.io profile URL."""
348
- value = (handle or "").strip()
349
- match = _PROFILE_URL.match(value)
350
- if match:
351
- value = match.group(1)
352
- value = value.lstrip("@").lower()
353
- if not _HANDLE.match(value):
354
- raise ValueError("Handles are 3-20 characters long and use lowercase letters, digits and hyphens.")
355
- return value
356
-
357
-
358
- def _badge_labels(badges: list[dict[str, Any]]) -> list[str]:
359
- """Readable badge labels, keeping only the highest step of each tiered badge.
360
-
361
- Tiered badges (10, 25, 100 audits and so on) share a "{value} ..." label and
362
- carry their threshold as the id suffix (bdg_audits_100), which is how the
363
- catalog is seeded. The public profile response does not include the
364
- threshold itself, so the suffix is the only place to read it from.
365
- """
366
- highest: dict[str, tuple[int, str]] = {}
367
- plain: list[str] = []
368
- for badge in badges:
369
- label = badge.get("label") or ""
370
- step = _BADGE_STEP.match(badge.get("id") or "")
371
- if "{value}" in label and step:
372
- value = int(step.group("value"))
373
- if value > highest.get(step.group("ladder"), (-1, ""))[0]:
374
- highest[step.group("ladder")] = (value, label.replace("{value}", f"{value:,}"))
375
- elif label:
376
- plain.append(" ".join(label.replace("{value}", "").split()))
377
- return [label for _, label in highest.values()] + plain
378
-
379
-
380
- def contributor(handle: str) -> dict[str, Any] | None:
381
- """A contributor's public profile, or None if there is no public profile.
382
-
383
- Fields the member chose to hide are absent upstream and come back as None
384
- here, so callers must treat every field except the handle as optional.
385
- """
386
- handle = normalize_handle(handle)
387
- data = _get(f"/v1/profiles/handle/{quote(handle, safe='')}", not_found_ok=True)
388
- if not data:
389
- return None
390
- tier = data.get("tier") if isinstance(data.get("tier"), dict) else None
391
- return {
392
- "handle": data.get("handle") or handle,
393
- "display_name": data.get("displayName") or data.get("handle") or handle,
394
- "member_since": _date(data.get("memberSince")),
395
- "karma": data.get("karma"),
396
- "tier": tier.get("label") if tier else None,
397
- "tier_color": tier.get("color") if tier else None,
398
- "badges": _badge_labels(data["badges"]) if "badges" in data else None,
399
- "accepted_items": data.get("acceptedItems"),
400
- "audits": data.get("audits"),
401
- "datasets_contributed_to": (
402
- [{"title": d.get("title"), "items": d.get("items")} for d in data["datasets"]] if "datasets" in data else None
403
- ),
404
- "published_credits": [
405
- {"title": c.get("title"), "hugging_face_url": c.get("hfUrl")}
406
- for c in data.get("publishedCredits") or []
407
- if c.get("hfUrl")
408
- ],
409
- "profile_url": f"{SITE_URL}/{handle}/",
410
- }
411
-
412
-
413
- # --- Agents -------------------------------------------------------------------------
414
-
415
-
416
- def platform_mcp_tools() -> list[dict[str, str]]:
417
- """DataBounty's own MCP tool catalog, with the credential scope each one needs."""
418
- tools = (_get("/mcp/tools") or {}).get("tools") or []
419
- return [
420
- {
421
- "name": tool.get("name") or "",
422
- "scope": tool.get("scope") or "",
423
- "description": " ".join((tool.get("description") or "").split()),
424
- }
425
- for tool in tools
426
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Read-only client for DataBounty's public API and the Hugging Face Hub.
3
+
4
+ Everything here reads public data: the same unauthenticated endpoints that
5
+ databounty.io renders from. Each response is kept in a small in-process cache
6
+ for CACHE_TTL_SECONDS, so a burst of visitors, or an agent calling the MCP tools
7
+ in a loop, never turns into a burst of upstream requests. Errors are never
8
+ cached, so a recovered upstream is picked up on the next call.
9
+
10
+ Profiles are read only from /v1/profiles/handle/:handle, which answers 404 for a
11
+ private profile and leaves out every field the member chose to hide. Nothing
12
+ here calls /v1/community/members/:handle, which applies neither rule.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import re
19
+ import threading
20
+ import time
21
+ from collections import OrderedDict
22
+ from collections.abc import Callable
23
+ from typing import Any
24
+ from urllib.parse import quote
25
+
26
+ import httpx
27
+ from huggingface_hub import HfApi
28
+
29
+ API_URL = os.environ.get("DATABOUNTY_API_URL", "https://api.databounty.io").rstrip("/")
30
+ SITE_URL = os.environ.get("DATABOUNTY_SITE_URL", "https://databounty.io").rstrip("/")
31
+ APP_URL = os.environ.get("DATABOUNTY_APP_URL", "https://console.databounty.io").rstrip("/")
32
+ HF_ORG = os.environ.get("DATABOUNTY_HF_ORG", "databounty-io")
33
+ CACHE_TTL_SECONDS = float(os.environ.get("CACHE_TTL_SECONDS", "120"))
34
+
35
+ # DataBounty's own MCP server. The dashboard origin proxies /mcp to the API, and
36
+ # the API binds every OAuth credential to that origin, so this is the URL
37
+ # clients must use (not API_URL).
38
+ MCP_URL = f"{APP_URL}/mcp"
39
+
40
+ DOMAINS = ("coding", "legal", "healthcare", "finance", "science")
41
+ DIFFICULTIES = ("beginner", "intermediate", "advanced")
42
+ TIERS = ("dharma", "bodhi", "moksha", "nirvana")
43
+
44
+ # Same rule the dashboard applies when a member claims a handle.
45
+ _HANDLE = re.compile(r"^[a-z0-9-]{3,20}$")
46
+ _POOL_ID = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
47
+ _PROFILE_URL = re.compile(r"^(?:https?://)?(?:www\.)?databounty\.io/+@?([^/?#]+)/*$", re.IGNORECASE)
48
+ _BADGE_STEP = re.compile(r"^(?P<ladder>.+)_(?P<value>\d+)$")
49
+
50
+ _PAGE_SIZE = {"/v1/community/catalog": 100, "/v1/community/pools": 50}
51
+ _MAX_PAGES = 5
52
+ _CACHE_MAX_ENTRIES = 256
53
+
54
+
55
+ class UpstreamError(RuntimeError):
56
+ """DataBounty's API could not be reached, or answered with an error."""
57
+
58
+
59
+ _http = httpx.Client(
60
+ base_url=API_URL,
61
+ timeout=httpx.Timeout(15.0, connect=5.0),
62
+ headers={
63
+ "Accept": "application/json",
64
+ "User-Agent": "databounty-space/1.0 (+https://huggingface.co/spaces/databounty-io/DataBounty)",
65
+ },
66
+ )
67
+ _hub = HfApi()
68
+ _cache: OrderedDict[tuple, tuple[float, Any]] = OrderedDict()
69
+ _lock = threading.Lock()
70
+
71
+
72
+ def _cached(key: tuple, load: Callable[[], Any]) -> Any:
73
+ now = time.monotonic()
74
+ with _lock:
75
+ hit = _cache.get(key)
76
+ if hit is not None and now - hit[0] < CACHE_TTL_SECONDS:
77
+ _cache.move_to_end(key)
78
+ return hit[1]
79
+ value = load() # raises on failure, so errors never reach the cache
80
+ with _lock:
81
+ _cache[key] = (now, value)
82
+ _cache.move_to_end(key)
83
+ # Keys include free-text search terms, so bound the cache rather than
84
+ # let a stream of unique queries grow it forever.
85
+ while len(_cache) > _CACHE_MAX_ENTRIES:
86
+ _cache.popitem(last=False)
87
+ return value
88
+
89
+
90
+ def _get(path: str, params: dict[str, Any] | None = None, *, not_found_ok: bool = False) -> Any:
91
+ query = {k: v for k, v in (params or {}).items() if v not in (None, "")}
92
+
93
+ def load() -> Any:
94
+ try:
95
+ resp = _http.get(path, params=query)
96
+ except httpx.HTTPError as exc:
97
+ raise UpstreamError(f"DataBounty's API could not be reached ({type(exc).__name__}).") from exc
98
+ if resp.status_code == 404 and not_found_ok:
99
+ return None
100
+ if resp.status_code >= 400:
101
+ raise UpstreamError(f"DataBounty's API answered HTTP {resp.status_code} for {path}.")
102
+ try:
103
+ return resp.json()
104
+ except ValueError as exc:
105
+ raise UpstreamError(f"DataBounty's API returned an unreadable response for {path}.") from exc
106
+
107
+ return _cached(("api", path, tuple(sorted(query.items()))), load)
108
+
109
+
110
+ def _all_pages(path: str, key: str) -> list[dict[str, Any]]:
111
+ rows: list[dict[str, Any]] = []
112
+ cursor = None
113
+ for _ in range(_MAX_PAGES):
114
+ page = _get(path, {"limit": _PAGE_SIZE[path], "cursor": cursor}) or {}
115
+ rows.extend(page.get(key) or [])
116
+ cursor = page.get("nextCursor")
117
+ if not cursor:
118
+ break
119
+ return rows
120
+
121
+
122
+ def _int(value: Any) -> int:
123
+ # Counts arrive as numbers or as numeric strings (targetItems is a BigInt
124
+ # serialised as text), so accept both.
125
+ try:
126
+ return int(value)
127
+ except (TypeError, ValueError):
128
+ return 0
129
+
130
+
131
+ def _pct(part: int, whole: int) -> float | None:
132
+ return round(100 * part / whole, 1) if whole else None
133
+
134
+
135
+ def _date(value: Any) -> str | None:
136
+ return value[:10] if isinstance(value, str) and len(value) >= 10 else None
137
+
138
+
139
+ # --- Programme totals and karma tiers -----------------------------------------
140
+
141
+
142
+ def stats() -> dict[str, Any]:
143
+ """Programme totals, the karma tier ladder and the top of the leaderboard."""
144
+ return _get("/v1/community/stats") or {}
145
+
146
+
147
+ def tiers() -> list[dict[str, Any]]:
148
+ """The live karma tier ladder, lowest threshold first."""
149
+ return sorted(stats().get("tiers") or [], key=lambda t: _int(t.get("minKarma")))
150
+
151
+
152
+ def tier_for(karma: int, ladder: list[dict[str, Any]]) -> dict[str, Any] | None:
153
+ current = None
154
+ for tier in ladder:
155
+ if karma >= _int(tier.get("minKarma")):
156
+ current = tier
157
+ return current
158
+
159
+
160
+ # --- Pools and datasets ---------------------------------------------------------
161
+
162
+
163
+ def _programme(row: dict[str, Any]) -> dict[str, Any]:
164
+ dataset_type = row.get("datasetType") or {}
165
+ pricing = row.get("karmaPricing") or {}
166
+ target = _int(row.get("targetItems"))
167
+ accepted = _int(row.get("acceptedItems"))
168
+ cleared = _int(row.get("clearedItems"))
169
+ pool_id = str(row.get("id") or "")
170
+ return {
171
+ "id": pool_id,
172
+ "title": row.get("title") or "",
173
+ "status": row.get("status") or "",
174
+ "dataset_type": dataset_type.get("name") or "",
175
+ "domain": dataset_type.get("domain") or "",
176
+ "category": row.get("datasetCategory") or "",
177
+ "language": row.get("language") or "",
178
+ "difficulty": row.get("difficulty") or "",
179
+ "trust_tier": dataset_type.get("trustTier") or "",
180
+ "target_items": target,
181
+ "accepted_items": accepted,
182
+ # Cleared items passed the automated checks and hold a slot in the pool;
183
+ # the ones not yet accepted are waiting on a human validator.
184
+ "in_review_items": max(cleared - accepted, 0),
185
+ "progress_pct": _pct(accepted, target),
186
+ "karma_per_accepted_item": pricing.get("contributorPerItem"),
187
+ "validator_karma_per_audited_item": pricing.get("validatorPerAuditedItem"),
188
+ "audit_coverage_pct": row.get("auditCoveragePct"),
189
+ "license": row.get("communityLicense") or "",
190
+ "page_url": f"{SITE_URL}/pools/{pool_id}/",
191
+ "contribute_url": f"{APP_URL}/contributor/pool/{pool_id}/",
192
+ }
193
+
194
+
195
+ def _catalog() -> list[dict[str, Any]]:
196
+ return _cached(("catalog",), lambda: _all_pages("/v1/community/catalog", "bounties"))
197
+
198
+
199
+ def open_pools(query: str = "", domain: str = "", difficulty: str = "") -> list[dict[str, Any]]:
200
+ """Pools currently open for contribution, optionally filtered.
201
+
202
+ "Open" is whatever the platform's own open-pools endpoint lists; the catalog
203
+ supplies the descriptive fields that endpoint leaves out.
204
+ """
205
+ open_ids = {row.get("id") for row in _all_pages("/v1/community/pools", "pools")}
206
+ needle = query.strip().lower()
207
+ pools = []
208
+ for row in _catalog():
209
+ if row.get("id") not in open_ids:
210
+ continue
211
+ pool = _programme(row)
212
+ if domain and pool["domain"] != domain:
213
+ continue
214
+ if difficulty and pool["difficulty"] != difficulty:
215
+ continue
216
+ haystack = " ".join((pool["title"], pool["dataset_type"], pool["language"], row.get("description") or ""))
217
+ if needle and needle not in haystack.lower():
218
+ continue
219
+ pools.append(pool)
220
+ return pools
221
+
222
+
223
+ def pool_details(pool_id: str) -> dict[str, Any] | None:
224
+ """One pool's full public record, or None when the id is unknown."""
225
+ pool_id = (pool_id or "").strip()
226
+ if not _POOL_ID.match(pool_id):
227
+ raise ValueError("That doesn't look like a DataBounty pool id.")
228
+ row = (_get(f"/v1/community/catalog/{quote(pool_id, safe='')}", not_found_ok=True) or {}).get("bounty")
229
+ if not row:
230
+ return None
231
+ pool = _programme(row)
232
+ dataset_type = row.get("datasetType") or {}
233
+ pool.update(
234
+ description=row.get("description") or "",
235
+ contributors=row.get("contributorCount"),
236
+ validators=row.get("validatorCount"),
237
+ duplicate_rate=row.get("duplicateRate"),
238
+ created_at=_date(row.get("createdAt")),
239
+ fields=[
240
+ {
241
+ "key": field.get("key"),
242
+ "label": field.get("label") or field.get("key"),
243
+ "role": field.get("role"),
244
+ "required": bool(field.get("required")),
245
+ "help": field.get("help") or "",
246
+ }
247
+ for field in dataset_type.get("fields") or []
248
+ ],
249
+ example_items=[
250
+ sample["fields"]
251
+ for sample in dataset_type.get("sampleAssets") or []
252
+ if isinstance(sample, dict) and isinstance(sample.get("fields"), dict) and sample["fields"]
253
+ ][:3],
254
+ hugging_face_dataset=row.get("huggingFaceDataset"),
255
+ )
256
+ return pool
257
+
258
+
259
+ def _hub_datasets() -> dict[str, dict[str, Any]]:
260
+ def load() -> dict[str, dict[str, Any]]:
261
+ return {
262
+ info.id: {"downloads": info.downloads, "likes": info.likes}
263
+ for info in _hub.list_datasets(author=HF_ORG, full=True)
264
+ }
265
+
266
+ try:
267
+ return _cached(("hub", HF_ORG), load)
268
+ except Exception:
269
+ # Download counts are decoration; the catalog is the source of truth,
270
+ # so a Hub hiccup must not take the datasets list down with it.
271
+ return {}
272
+
273
+
274
+ def published_datasets() -> list[dict[str, Any]]:
275
+ """Datasets DataBounty has published, newest first, with Hub download counts."""
276
+ hub = _hub_datasets()
277
+ datasets = []
278
+ for row in _catalog():
279
+ if row.get("publicationStatus") != "published":
280
+ continue
281
+ pool = _programme(row)
282
+ targets = (row.get("publication") or {}).get("targets") or []
283
+
284
+ def published_url(target: str) -> str | None:
285
+ return next(
286
+ (t.get("url") for t in targets if t.get("target") == target and t.get("state") == "published" and t.get("url")),
287
+ None,
288
+ )
289
+
290
+ repo_id = row.get("huggingFaceDataset") or None
291
+ counts = hub.get(repo_id or "", {})
292
+ datasets.append(
293
+ {
294
+ "title": pool["title"],
295
+ "dataset_type": pool["dataset_type"],
296
+ "items": pool["accepted_items"],
297
+ "license": pool["license"],
298
+ "difficulty": pool["difficulty"],
299
+ "published_on": _date(row.get("deliveredAt")),
300
+ "hugging_face_id": repo_id,
301
+ "hugging_face_url": published_url("huggingface") or (f"https://huggingface.co/datasets/{repo_id}" if repo_id else None),
302
+ "github_url": published_url("github"),
303
+ "downloads": counts.get("downloads"),
304
+ "likes": counts.get("likes"),
305
+ "page_url": pool["page_url"],
306
+ }
307
+ )
308
+ datasets.sort(key=lambda d: d["published_on"] or "", reverse=True)
309
+ return datasets
310
+
311
+
312
+ # --- People -----------------------------------------------------------------------
313
+
314
+
315
+ def leaderboard(limit: int = 50, tier: str = "", query: str = "") -> list[dict[str, Any]]:
316
+ """The all-time karma leaderboard.
317
+
318
+ The API already leaves out members who hide their karma, so this never
319
+ shows more than databounty.io's own leaderboard does.
320
+ """
321
+ params = {
322
+ "limit": max(1, min(_int(limit) or 50, 100)),
323
+ "tier": tier if tier in TIERS else None,
324
+ "q": query.strip()[:60] or None,
325
+ }
326
+ rows = (_get("/v1/community/leaderboard", params) or {}).get("leaderboard") or []
327
+ ladder = tiers()
328
+ board = []
329
+ for row in rows:
330
+ karma = _int(row.get("karma"))
331
+ handle = row.get("handle")
332
+ board.append(
333
+ {
334
+ "rank": row.get("rank"),
335
+ "handle": handle,
336
+ "display_name": row.get("displayName") or handle,
337
+ "karma": karma,
338
+ "accepted_items": row.get("acceptedItems"),
339
+ "tier": (tier_for(karma, ladder) or {}).get("label"),
340
+ "profile_url": f"{SITE_URL}/{handle}/" if handle else None,
341
+ }
342
+ )
343
+ return board
344
+
345
+
346
+ def normalize_handle(handle: str) -> str:
347
+ """Accept "starlord", "@starlord" or a pasted databounty.io profile URL."""
348
+ value = (handle or "").strip()
349
+ match = _PROFILE_URL.match(value)
350
+ if match:
351
+ value = match.group(1)
352
+ value = value.lstrip("@").lower()
353
+ if not _HANDLE.match(value):
354
+ raise ValueError("Handles are 3-20 characters long and use lowercase letters, digits and hyphens.")
355
+ return value
356
+
357
+
358
+ def _badge_labels(badges: list[dict[str, Any]]) -> list[str]:
359
+ """Readable badge labels, keeping only the highest step of each tiered badge.
360
+
361
+ Tiered badges (10, 25, 100 audits and so on) share a "{value} ..." label and
362
+ carry their threshold as the id suffix (bdg_audits_100), which is how the
363
+ catalog is seeded. The public profile response does not include the
364
+ threshold itself, so the suffix is the only place to read it from.
365
+ """
366
+ highest: dict[str, tuple[int, str]] = {}
367
+ plain: list[str] = []
368
+ for badge in badges:
369
+ label = badge.get("label") or ""
370
+ step = _BADGE_STEP.match(badge.get("id") or "")
371
+ if "{value}" in label and step:
372
+ value = int(step.group("value"))
373
+ if value > highest.get(step.group("ladder"), (-1, ""))[0]:
374
+ highest[step.group("ladder")] = (value, label.replace("{value}", f"{value:,}"))
375
+ elif label:
376
+ plain.append(" ".join(label.replace("{value}", "").split()))
377
+ return [label for _, label in highest.values()] + plain
378
+
379
+
380
+ def contributor(handle: str) -> dict[str, Any] | None:
381
+ """A contributor's public profile, or None if there is no public profile.
382
+
383
+ Fields the member chose to hide are absent upstream and come back as None
384
+ here, so callers must treat every field except the handle as optional.
385
+ """
386
+ handle = normalize_handle(handle)
387
+ data = _get(f"/v1/profiles/handle/{quote(handle, safe='')}", not_found_ok=True)
388
+ if not data:
389
+ return None
390
+ tier = data.get("tier") if isinstance(data.get("tier"), dict) else None
391
+ return {
392
+ "handle": data.get("handle") or handle,
393
+ "display_name": data.get("displayName") or data.get("handle") or handle,
394
+ "member_since": _date(data.get("memberSince")),
395
+ "karma": data.get("karma"),
396
+ "tier": tier.get("label") if tier else None,
397
+ "tier_color": tier.get("color") if tier else None,
398
+ "badges": _badge_labels(data["badges"]) if "badges" in data else None,
399
+ "accepted_items": data.get("acceptedItems"),
400
+ "audits": data.get("audits"),
401
+ "datasets_contributed_to": (
402
+ [{"title": d.get("title"), "items": d.get("items")} for d in data["datasets"]] if "datasets" in data else None
403
+ ),
404
+ "published_credits": [
405
+ {"title": c.get("title"), "hugging_face_url": c.get("hfUrl")}
406
+ for c in data.get("publishedCredits") or []
407
+ if c.get("hfUrl")
408
+ ],
409
+ "profile_url": f"{SITE_URL}/{handle}/",
410
+ }
411
+
412
+
413
+ # --- Requesting a dataset ------------------------------------------------------------
414
+
415
+
416
+ def request_catalog() -> dict[str, Any]:
417
+ """DataBounty's planner catalog: the dataset types open for requests, with the languages,
418
+ difficulty levels and hand-check shares each one offers, and how many reference samples a
419
+ request wants. Public, like the rest of what the Space reads here."""
420
+ return _get("/v1/planner/catalog") or {}
421
+
422
+
423
+ def requesting_open() -> bool:
424
+ """Whether DataBounty takes dataset requests from other apps (its request_dataset MCP tool).
425
+ Until it does, the Space links to databounty.io instead."""
426
+ return any(tool["name"] == "request_dataset" for tool in platform_mcp_tools())
427
+
428
+
429
+ # --- Agents -------------------------------------------------------------------------
430
+
431
+
432
+ def platform_mcp_tools() -> list[dict[str, str]]:
433
+ """DataBounty's own MCP tool catalog, with the credential scope each one needs."""
434
+ tools = (_get("/mcp/tools") or {}).get("tools") or []
435
+ return [
436
+ {
437
+ "name": tool.get("name") or "",
438
+ "scope": tool.get("scope") or "",
439
+ "description": " ".join((tool.get("description") or "").split()),
440
+ }
441
+ for tool in tools
442
+ ]
requesting.py ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Requesting a dataset from the Space: the form's choices, a request's arguments, and how a
3
+ person's requests read back.
4
+
5
+ Plain data in, plain data out, like contribute.py, validate.py and sponsor.py. The choices come from
6
+ DataBounty's public planner catalog (the dataset types open for requests, with the languages and
7
+ difficulty levels each can honour). A request goes to DataBounty's request_dataset tool, which checks
8
+ it all again, and an admin reviews it before a pool opens. The defaults are the console planner's.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from typing import Any
15
+
16
+ # The licences DataBounty can publish a dataset under (its lib/publication/license-texts.ts). It
17
+ # refuses any other.
18
+ LICENCES = [
19
+ ("CC-BY-4.0", "CC BY 4.0", "Anyone may use it, crediting the contributors."),
20
+ ("CC-BY-SA-4.0", "CC BY-SA 4.0", "Credit the contributors, and share changes under the same licence."),
21
+ ("CC0-1.0", "CC0 1.0", "Public domain: no conditions at all."),
22
+ ("ODC-By-1.0", "ODC-By 1.0", "Open data licence, crediting the contributors."),
23
+ ]
24
+ MIXES = {"mostly_beginner": "Mostly beginner", "balanced": "Balanced", "mostly_advanced": "Mostly advanced"}
25
+ DEFAULTS = {"targetItems": 500, "difficultyMix": "balanced", "auditCoveragePct": 25, "proposedLicense": "CC-BY-4.0"}
26
+ TITLE_LENGTH = (5, 120)
27
+ DESCRIPTION_LENGTH = (20, 2000)
28
+ TARGET_ITEMS = (10, 100_000)
29
+ SHORT_TEXT = 60 # language and framework
30
+ # Characters DataBounty refuses in a request's text (control and bidirectional-override characters).
31
+ UNSAFE = re.compile("[\u0000-\u001f\u007f-\u009f‎‏‪-‮⁦-⁩]")
32
+ KEY = re.compile(r"^[A-Za-z0-9_.:-]{8,200}$")
33
+
34
+ # Where a request stands, from the requester's side: (label, tone).
35
+ REQUEST_STATUS = {
36
+ "submitted": ("Waiting for review", "info"),
37
+ "under_review": ("In review", "review"),
38
+ "changes_requested": ("Changes asked for", "warn"),
39
+ "approved": ("Approved", "ok"),
40
+ "implemented": ("Pool is open", "ok"),
41
+ "declined": ("Declined", "bad"),
42
+ "disputed": ("Disputed", "warn"),
43
+ }
44
+ # DataBounty freezes a request's reference samples once it's decided (services/artifacts.ts).
45
+ SAMPLES_OPEN = {"submitted", "under_review", "changes_requested"}
46
+
47
+
48
+ def mixes_for(levels: Any) -> list[str]:
49
+ """The difficulty mixes a dataset type can honour, by DataBounty's own rule (coherenceProblems in
50
+ services/planner.ts): mostly beginner needs a beginner level, mostly advanced an advanced or an
51
+ expert one. A type that declares no levels takes any mix."""
52
+ levels = {str(level) for level in levels or []}
53
+ if not levels:
54
+ return list(MIXES)
55
+ allowed = {"balanced"}
56
+ if "beginner" in levels:
57
+ allowed.add("mostly_beginner")
58
+ if levels & {"advanced", "expert"}:
59
+ allowed.add("mostly_advanced")
60
+ return [mix for mix in MIXES if mix in allowed]
61
+
62
+
63
+ def type_option(t: dict[str, Any]) -> dict[str, Any]:
64
+ """One dataset type, as the form offers it."""
65
+ support = t.get("languageSupport") or {}
66
+ mode = support.get("mode") if support.get("mode") in {"fixed", "choice", "any", "none"} else "any"
67
+ languages = [str(l.get("label") or l.get("id") or "") for l in support.get("languages") or [] if isinstance(l, dict)]
68
+ audit = sorted({int(a) for a in t.get("auditOptions") or [] if isinstance(a, (int, float)) and 0 <= a <= 100})
69
+ return {
70
+ "id": str(t.get("id") or ""),
71
+ "name": str(t.get("name") or t.get("id") or ""),
72
+ "description": " ".join(str(t.get("description") or "").split()),
73
+ "category": str(t.get("category") or "other").replace("_", " "),
74
+ "languageMode": mode,
75
+ "languages": [l for l in languages if l],
76
+ "mixes": [{"value": m, "label": MIXES[m]} for m in mixes_for(t.get("difficultyLevels"))],
77
+ "auditOptions": audit or [DEFAULTS["auditCoveragePct"]],
78
+ # The item's field keys, for a sample template.
79
+ "fields": [str(f["key"]) for f in t.get("fields") or [] if isinstance(f, dict) and f.get("key")],
80
+ }
81
+
82
+
83
+ def catalog_view(data: dict[str, Any]) -> dict[str, Any]:
84
+ """The request form's choices, from DataBounty's planner catalog."""
85
+ types = [
86
+ type_option(t)
87
+ for t in data.get("datasetTypes") or []
88
+ if isinstance(t, dict) and t.get("id") and (t.get("status") or "active") == "active"
89
+ ]
90
+ types.sort(key=lambda t: (t["category"], t["name"].lower()))
91
+ gate = data.get("sampleGate") or {}
92
+ wanted = gate.get("min")
93
+ return {
94
+ "types": types,
95
+ "sampleMin": wanted if isinstance(wanted, int) and wanted > 0 else 3,
96
+ "licences": [{"value": v, "label": label, "hint": hint} for v, label, hint in LICENCES],
97
+ "defaults": DEFAULTS,
98
+ }
99
+
100
+
101
+ def _text(body: dict[str, Any], key: str) -> str:
102
+ return " ".join(str(body.get(key) or "").split()) if key != "description" else str(body.get(key) or "").strip()
103
+
104
+
105
+ def request_arguments(body: dict[str, Any], types: dict[str, dict[str, Any]]) -> tuple[dict[str, Any] | None, str]:
106
+ """request_dataset's arguments for this form, or what to fix first. DataBounty checks it all again."""
107
+ t = types.get(str(body.get("datasetTypeId") or ""))
108
+ if not t:
109
+ return None, "Choose a dataset type."
110
+ title, description = _text(body, "title"), _text(body, "description")
111
+ language, framework = _text(body, "language"), _text(body, "framework")
112
+ if len(title) < TITLE_LENGTH[0]:
113
+ return None, f"Give the request a title of at least {TITLE_LENGTH[0]} characters."
114
+ if len(title) > TITLE_LENGTH[1]:
115
+ return None, f"Keep the title under {TITLE_LENGTH[1]} characters."
116
+ if len(description) < DESCRIPTION_LENGTH[0]:
117
+ return None, f"Describe the dataset in at least {DESCRIPTION_LENGTH[0]} characters."
118
+ if len(description) > DESCRIPTION_LENGTH[1]:
119
+ return None, f"Keep the description under {DESCRIPTION_LENGTH[1]} characters."
120
+ if any(UNSAFE.search(value) for value in (title, language, framework)):
121
+ return None, "Remove the hidden control characters from the title, language or framework."
122
+ if len(language) > SHORT_TEXT or len(framework) > SHORT_TEXT:
123
+ return None, f"Keep the language and framework under {SHORT_TEXT} characters."
124
+ try:
125
+ items = int(body.get("targetItems"))
126
+ except (TypeError, ValueError):
127
+ items = 0
128
+ if not TARGET_ITEMS[0] <= items <= TARGET_ITEMS[1]:
129
+ return None, f"Ask for between {TARGET_ITEMS[0]} and {TARGET_ITEMS[1]:,} items."
130
+ mix = str(body.get("difficultyMix") or "")
131
+ if mix not in {m["value"] for m in t["mixes"]}:
132
+ return None, "Choose a difficulty this dataset type offers."
133
+ try:
134
+ audit = int(body.get("auditCoveragePct"))
135
+ except (TypeError, ValueError):
136
+ audit = -1
137
+ if not 0 <= audit <= 100:
138
+ return None, "Choose how many items validators check by hand."
139
+ licence = str(body.get("proposedLicense") or "")
140
+ if licence not in {v for v, _, _ in LICENCES}:
141
+ return None, "Choose a licence."
142
+
143
+ arguments: dict[str, Any] = {
144
+ "datasetTypeId": t["id"],
145
+ "title": title,
146
+ "description": description,
147
+ "targetItems": items,
148
+ "difficultyMix": mix,
149
+ "auditCoveragePct": audit,
150
+ "proposedLicense": licence,
151
+ }
152
+ if t["languageMode"] == "fixed" and t["languages"]:
153
+ arguments["language"] = t["languages"][0]
154
+ elif t["languageMode"] == "choice":
155
+ match = next((l for l in t["languages"] if l.lower() == language.lower()), None)
156
+ if not match:
157
+ return None, f"Choose the language: {', '.join(t['languages'])}."
158
+ arguments["language"] = match
159
+ elif t["languageMode"] == "any" and language:
160
+ arguments["language"] = language
161
+ if framework:
162
+ arguments["framework"] = framework
163
+ key = str(body.get("idempotencyKey") or "")
164
+ if KEY.match(key):
165
+ arguments["idempotencyKey"] = key # the same form sent twice files one request
166
+ return arguments, ""
167
+
168
+
169
+ def _count(value: Any) -> int:
170
+ return value if isinstance(value, int) and value > 0 else 0
171
+
172
+
173
+ def request_row(r: dict[str, Any], type_names: dict[str, str] | None = None) -> dict[str, Any]:
174
+ """One of the person's requests, from list_my_dataset_requests."""
175
+ status = str(r.get("status") or "")
176
+ label, tone = REQUEST_STATUS.get(status, (status.replace("_", " ").capitalize() or "Unknown", "info"))
177
+ gate = r.get("sampleGate") or {}
178
+ approved, pending, rejected = _count(gate.get("approved")), _count(gate.get("pending")), _count(gate.get("rejected"))
179
+ wanted = _count(gate.get("min")) or 3
180
+ limit = _count(gate.get("max")) or wanted
181
+ if gate.get("ok"):
182
+ sample_text = "Example items approved."
183
+ elif approved:
184
+ sample_text = f"{approved} of {wanted} example items approved" + (f", {pending} waiting for review." if pending else ".")
185
+ elif pending:
186
+ sample_text = f"{pending} example item{'s' if pending != 1 else ''} waiting for review. The pool can open once one is approved."
187
+ else:
188
+ sample_text = f"Add up to {limit} example items. The pool can open once an admin approves one."
189
+ type_id = str(r.get("datasetTypeId") or "")
190
+ return {
191
+ "id": r.get("id"),
192
+ "title": r.get("title") or "",
193
+ "typeId": type_id,
194
+ "type": (type_names or {}).get(type_id) or type_id.replace("_", " "),
195
+ "status": status,
196
+ "statusLabel": label,
197
+ "statusTone": tone,
198
+ "date": str(r.get("createdAt") or "")[:10],
199
+ "targetItems": r.get("targetItems"),
200
+ "difficulty": MIXES.get(str(r.get("difficultyMix") or ""), ""),
201
+ "language": r.get("language") or "",
202
+ "licence": r.get("proposedLicense") or "",
203
+ "adminNote": r.get("adminNote") or "",
204
+ "samples": {"approved": approved, "pending": pending, "rejected": rejected, "wanted": wanted, "ok": bool(gate.get("ok"))},
205
+ "sampleText": sample_text,
206
+ # A rejected sample frees its slot, as DataBounty counts them.
207
+ "canAddSample": status in SAMPLES_OPEN and approved + pending < limit,
208
+ "poolId": r.get("mintedBountyId") or "",
209
+ }
web/app.css CHANGED
@@ -1,424 +1,439 @@
1
- /* SPDX-License-Identifier: Apache-2.0 */
2
- /* The DataBounty console's look, for the Space. Tokens are the console's own
3
- (apps/web/app/globals.css): ink on paper, white cards, lime accent, a dark sidebar. */
4
-
5
- :root {
6
- --ink: #14170f;
7
- --ink-soft: #6b7360;
8
- --paper: #f3f4f0;
9
- --panel: #f7f8f4;
10
- --line: #e4e6df;
11
- --line-soft: #eef0e8;
12
- --line-strong: #868d80;
13
- --lime: #a3f60a;
14
- --lime-ink: #4f7a00;
15
- --dark: #0a0c0a;
16
- --dark-line: #1c2a19;
17
- --dark-line-soft: #2a3a26;
18
- --dark-text: #eef2ec;
19
- --dark-muted: #a4ad9c;
20
- --dark-soft: #8a9382;
21
- --dark-track: #152012;
22
- --dark-nav-hover: #12160f;
23
- --nav-active: #1a2810;
24
- --karma: #7c5cc4;
25
- --karma-soft: #f0ebfa;
26
- --karma-border: #e2d8f6;
27
- --accent-strong: #5f7a1e;
28
- --accent-strong-hover: #4d6418;
29
- --accent-soft: #f2f7e4;
30
- --accent-soft-border: #d5deba;
31
- --success: #1f8a5b;
32
- --success-soft: #e6f5ec;
33
- --success-border: #bfe6cd;
34
- --danger: #c2334d;
35
- --danger-soft: #fbe7ea;
36
- --danger-border: #f2c4cd;
37
- --warn: #8a6412;
38
- --warn-soft: #fbf6e9;
39
- --warn-border: #eadbb5;
40
- --info: #2f5f96;
41
- --info-soft: #eef4fb;
42
- --info-border: #d4e3f5;
43
- --font-sans: "Geist", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
44
- --font-mono: "Geist Mono", ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
45
- }
46
-
47
- * { box-sizing: border-box; }
48
- html, body { margin: 0; }
49
- body {
50
- background: var(--paper);
51
- color: var(--ink);
52
- font-family: var(--font-sans);
53
- font-size: 14px;
54
- line-height: 1.5;
55
- font-feature-settings: "tnum" 1;
56
- -webkit-font-smoothing: antialiased;
57
- }
58
- a { color: inherit; }
59
- a.plain { text-decoration: none; }
60
- a.plain:hover { text-decoration: underline; text-underline-offset: 3px; }
61
- a.pill.plain:hover { text-decoration: none; border-color: var(--karma); }
62
- button, input, select, textarea { font: inherit; color: inherit; }
63
- ::selection { background: var(--lime); color: var(--dark); }
64
- [hidden] { display: none !important; }
65
- .i { width: 15px; height: 15px; flex: none; vertical-align: -2px; }
66
-
67
- /* --- Shell ----------------------------------------------------------------------------------- */
68
- .shell { display: grid; grid-template-columns: 240px minmax(0, 1fr); min-height: 100vh; }
69
- .sidebar {
70
- position: sticky; top: 0; height: 100vh;
71
- display: flex; flex-direction: column;
72
- background: var(--dark); color: var(--dark-text);
73
- border-right: 1px solid var(--dark-line);
74
- }
75
- .brand {
76
- display: flex; align-items: center; gap: 9px;
77
- padding: 20px 20px 19px; border-bottom: 1px solid var(--dark-line);
78
- text-decoration: none;
79
- }
80
- .brand img { width: 22px; height: 22px; }
81
- .brand span { font-family: var(--font-mono); font-weight: 700; font-size: 17px; letter-spacing: -0.03em; color: #fff; }
82
- .brand span b { color: var(--lime); font-weight: 700; }
83
- .nav { display: grid; gap: 3px; padding: 16px 12px; }
84
- .nav a {
85
- display: flex; align-items: center; gap: 12px;
86
- padding: 10px 12px; border-radius: 8px;
87
- font-family: var(--font-mono); font-size: 13px; color: var(--dark-muted); text-decoration: none;
88
- transition: background .12s, color .12s;
89
- }
90
- .nav a:hover { background: var(--dark-nav-hover); color: var(--dark-text); }
91
- .nav a.active { background: var(--nav-active); color: var(--lime); }
92
- .nav .badge { margin-left: auto; min-width: 18px; padding: 0 6px; border-radius: 999px; background: var(--lime); color: var(--dark); font-size: 10px; font-weight: 700; line-height: 18px; text-align: center; }
93
- .side-bottom { margin-top: auto; padding: 12px; border-top: 1px solid var(--dark-line); display: grid; gap: 8px; }
94
- .side-karma { border: 1px solid var(--dark-line-soft); border-radius: 10px; padding: 11px 13px; background: #0c100b; }
95
- .side-karma .row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
96
- .micro-dark { font-family: var(--font-mono); font-size: 10px; letter-spacing: .07em; text-transform: uppercase; color: var(--dark-soft); }
97
- .side-karma .pts { font-family: var(--font-mono); font-weight: 600; font-size: 14px; color: #fff; }
98
- .side-karma .to { font-family: var(--font-mono); font-size: 11px; color: var(--dark-soft); }
99
- .chip-dark { font-family: var(--font-mono); font-size: 10px; padding: 1px 7px; border-radius: 999px; background: #1f2a17; color: var(--dark-muted); }
100
- .bar-dark { height: 3px; margin-top: 9px; border-radius: 999px; background: var(--dark-track); overflow: hidden; }
101
- .bar-dark span { display: block; height: 100%; background: var(--lime); }
102
- .user-chip { display: flex; align-items: center; gap: 10px; padding: 6px 4px; }
103
- .avatar {
104
- width: 32px; height: 32px; flex: none; border-radius: 50%;
105
- display: grid; place-items: center;
106
- background: #161b13; border: 1px solid var(--dark-line-soft);
107
- font-family: var(--font-mono); font-weight: 600; font-size: 13px; color: #fff;
108
- }
109
- .user-chip .who { min-width: 0; }
110
- .user-chip b { display: block; font-family: var(--font-mono); font-size: 12.5px; font-weight: 600; color: #fff; }
111
- .user-chip span { display: block; font-family: var(--font-mono); font-size: 11px; color: var(--dark-soft); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
112
- .side-link { border: 0; background: none; padding: 4px; color: var(--dark-soft); font-family: var(--font-mono); font-size: 11.5px; text-align: left; cursor: pointer; }
113
- .side-link:hover { color: var(--lime); }
114
- .side-signin { display: grid; gap: 8px; padding: 4px; }
115
- .side-signin p { margin: 0; font-size: 12px; color: var(--dark-soft); line-height: 1.45; }
116
- .top-action { display: none; }
117
-
118
- .main { min-width: 0; }
119
- .page { max-width: 1120px; padding: 34px 40px 72px; animation: fade .18s ease-out; }
120
- @keyframes fade { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
121
- @media (prefers-reduced-motion: reduce) { .page { animation: none; } }
122
-
123
- /* --- Type ------------------------------------------------------------------------------------- */
124
- .page-head { margin-bottom: 22px; }
125
- h1.title { margin: 0 0 6px; font-family: var(--font-mono); font-weight: 600; font-size: 23px; letter-spacing: -0.02em; overflow-wrap: anywhere; }
126
- .sub { margin: 0; color: var(--ink-soft); font-size: 14px; }
127
- h2.section { margin: 34px 0 4px; font-family: var(--font-mono); font-weight: 600; font-size: 16px; letter-spacing: -0.01em; }
128
- .section-sub { margin: 0 0 14px; color: var(--ink-soft); font-size: 13px; }
129
- .micro { font-family: var(--font-mono); font-size: 10px; letter-spacing: .07em; text-transform: uppercase; color: var(--ink-soft); }
130
- .mono { font-family: var(--font-mono); }
131
- .small { font-size: 11.5px; }
132
- .muted { color: var(--ink-soft); }
133
- .strong { font-weight: 600; }
134
- .karma-text { color: var(--karma); }
135
- .back { display: inline-flex; align-items: center; gap: 6px; margin-bottom: 14px; font-family: var(--font-mono); font-size: 12px; color: var(--ink-soft); text-decoration: none; }
136
- .back:hover { color: var(--ink); }
137
-
138
- /* --- Surfaces ----------------------------------------------------------------------------------- */
139
- .card { background: #fff; border: 1px solid var(--line); border-radius: 14px; }
140
- .pad { padding: 20px 22px; }
141
- .stack { display: grid; gap: 12px; }
142
- .row { display: flex; align-items: center; gap: 10px; }
143
- .between { justify-content: space-between; }
144
- .wrap { flex-wrap: wrap; }
145
- .grow { flex: 1; min-width: 0; }
146
-
147
- .pill {
148
- display: inline-flex; align-items: center; gap: 5px;
149
- padding: 1px 9px; border-radius: 999px; border: 1px solid var(--line);
150
- background: var(--panel); color: var(--ink-soft);
151
- font-family: var(--font-mono); font-size: 11px; line-height: 1.7; white-space: nowrap;
152
- }
153
- .pill-karma, .pill-review { background: var(--karma-soft); border-color: var(--karma-border); color: var(--karma); }
154
- .pill-ok { background: var(--success-soft); border-color: var(--success-border); color: var(--success); }
155
- .pill-warn { background: var(--warn-soft); border-color: var(--warn-border); color: var(--warn); }
156
- .pill-bad { background: var(--danger-soft); border-color: var(--danger-border); color: var(--danger); }
157
- .pill-info { background: var(--info-soft); border-color: var(--info-border); color: var(--info); }
158
- .pill-lime { background: var(--accent-soft); border-color: var(--accent-soft-border); color: var(--accent-strong); }
159
- .dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; display: inline-block; }
160
-
161
- .btn {
162
- display: inline-flex; align-items: center; justify-content: center; gap: 8px;
163
- padding: 9px 16px; border-radius: 8px; border: 1px solid var(--line-strong);
164
- background: #fff; color: var(--ink); text-decoration: none; cursor: pointer;
165
- font-family: var(--font-mono); font-size: 12.5px; font-weight: 500; white-space: nowrap;
166
- transition: background .12s, border-color .12s, opacity .12s;
167
- }
168
- .btn:hover { border-color: var(--ink); }
169
- .btn-primary { background: var(--ink); border-color: var(--ink); color: var(--lime); }
170
- .btn-primary:hover { background: #000; border-color: #000; }
171
- .btn-submit { background: var(--accent-strong); border-color: var(--accent-strong); color: #fff; }
172
- .btn-submit:hover { background: var(--accent-strong-hover); border-color: var(--accent-strong-hover); }
173
- .btn-lime { background: var(--lime); border-color: var(--lime); color: var(--dark); }
174
- .btn-lime:hover { background: #b8ff3d; border-color: #b8ff3d; }
175
- .btn-sm { padding: 6px 11px; font-size: 11.5px; }
176
- .btn-wide { width: 100%; }
177
- .btn[disabled], .btn[aria-disabled="true"] { opacity: .45; cursor: not-allowed; pointer-events: none; }
178
-
179
- .input, .select, .textarea {
180
- width: 100%; padding: 10px 12px; border-radius: 8px;
181
- border: 1px solid var(--line); background: #fff; outline: none;
182
- font-size: 14px; transition: border-color .12s, box-shadow .12s;
183
- }
184
- .input::placeholder, .textarea::placeholder { font-family: var(--font-mono); font-size: 12.5px; color: #9aa08d; }
185
- .input:focus, .select:focus, .textarea:focus { border-color: var(--ink); box-shadow: 0 0 0 3px rgba(20, 23, 15, .06); }
186
- .select { appearance: none; padding-right: 34px; font-family: var(--font-mono); font-size: 12.5px; cursor: pointer;
187
- background: #fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b7360' stroke-width='2'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E") no-repeat right 12px center; }
188
- .textarea { resize: vertical; min-height: 92px; }
189
- .textarea.code {
190
- min-height: 170px; background: var(--panel); tab-size: 4;
191
- font-family: var(--font-mono); font-size: 12.5px; line-height: 1.6; white-space: pre;
192
- }
193
- .search { position: relative; }
194
- .search .i { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--ink-soft); }
195
- .search .input { padding-left: 34px; font-family: var(--font-mono); font-size: 12.5px; }
196
-
197
- .progress { height: 4px; border-radius: 999px; background: var(--line-soft); overflow: hidden; }
198
- .progress span { display: block; height: 100%; min-width: 3px; border-radius: inherit; background: var(--ink); }
199
-
200
- /* --- Workspace ------------------------------------------------------------------------------------- */
201
- .karma-card { display: grid; grid-template-columns: auto auto 1fr auto; align-items: center; gap: 22px; padding: 20px 22px; }
202
- .karma-icon { width: 42px; height: 42px; border-radius: 10px; display: grid; place-items: center; background: var(--karma-soft); color: var(--karma); }
203
- .karma-icon .i { width: 19px; height: 19px; }
204
- .karma-num { display: flex; align-items: baseline; gap: 7px; }
205
- .karma-num b { font-family: var(--font-mono); font-size: 26px; font-weight: 600; line-height: 1; }
206
- .chip { padding: 1px 8px; border-radius: 6px; background: var(--line-soft); font-family: var(--font-mono); font-size: 11px; color: var(--ink); }
207
- .karma-next .row { justify-content: space-between; margin-bottom: 7px; }
208
- .stats { display: grid; grid-template-columns: repeat(4, 1fr); margin-top: 14px; overflow: hidden; }
209
- .stat { padding: 14px 16px; border-left: 1px solid var(--line); border-bottom: 2px solid transparent; }
210
- .stat:first-child { border-left: 0; }
211
- .stat .micro { display: flex; align-items: center; gap: 6px; }
212
- .stat .micro .i { width: 12px; height: 12px; }
213
- .stat b { display: block; margin-top: 6px; font-family: var(--font-mono); font-size: 21px; font-weight: 600; line-height: 1.1; }
214
- .stat-ok { border-bottom-color: #8fbb3a; } .stat-ok b { color: var(--accent-strong); }
215
- .stat-warn { border-bottom-color: #d9a43a; } .stat-warn b { color: var(--warn); }
216
-
217
- .signin-card { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 20px; padding: 22px 24px; }
218
- .signin-card img { width: 40px; height: 40px; }
219
- .signin-card h2 { margin: 0 0 3px; font-family: var(--font-mono); font-size: 16px; font-weight: 600; }
220
- .signin-card p { margin: 0; color: var(--ink-soft); font-size: 13.5px; }
221
- .signin-actions { display: grid; gap: 6px; justify-items: end; }
222
- .signin-actions .note { font-size: 11.5px; color: var(--ink-soft); max-width: 260px; text-align: right; }
223
- .setup-card { padding: 20px 22px; }
224
- .setup-card h2 { margin: 0 0 4px; font-family: var(--font-mono); font-size: 16px; font-weight: 600; }
225
- .setup-card p { margin: 0 0 14px; color: var(--ink-soft); font-size: 13.5px; }
226
- .setup-card .row .input { max-width: 280px; }
227
-
228
- .filters { display: grid; grid-template-columns: minmax(200px, 320px) 200px 1fr; gap: 8px; align-items: center; margin-bottom: 12px; }
229
- .filters .count { justify-self: end; font-family: var(--font-mono); font-size: 11.5px; color: var(--ink-soft); }
230
- .rows { display: grid; gap: 12px; }
231
- .pool-row, .sub-row {
232
- position: relative; display: grid; gap: 9px; padding: 18px 50px 18px 20px;
233
- text-decoration: none; transition: border-color .12s, box-shadow .12s;
234
- }
235
- .pool-row:hover, .sub-row:hover { border-color: #cfd3c6; box-shadow: 0 1px 2px rgba(20, 23, 15, .04); }
236
- .pool-row .head, .sub-row .head { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; }
237
- .pool-title, .sub-title { font-weight: 600; font-size: 14.5px; }
238
- .pool-row .cap { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 12px; }
239
- .pool-row .cap .progress { height: 3px; }
240
- .row-arrow { position: absolute; right: 20px; top: 20px; color: var(--ink-soft); }
241
- .tabs-pills { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
242
- .tabs-pills button {
243
- padding: 5px 13px; border-radius: 999px; border: 1px solid var(--line); background: #fff;
244
- font-family: var(--font-mono); font-size: 12px; color: var(--ink-soft); cursor: pointer;
245
- }
246
- .tabs-pills button.on { background: var(--ink); border-color: var(--ink); color: var(--lime); }
247
- .empty { padding: 26px; text-align: center; color: var(--ink-soft); font-size: 13.5px; }
248
- .btn.more { justify-self: center; margin-top: 2px; }
249
-
250
- /* --- Pool page ----------------------------------------------------------------------------------------- */
251
- .cap-card { padding: 18px 22px; }
252
- .cap-card .row { justify-content: space-between; flex-wrap: wrap; }
253
- .contract { margin-top: 14px; padding: 22px; }
254
- .contract h3 { margin: 4px 0 10px; font-size: 16px; font-weight: 600; }
255
- .chips { display: flex; flex-wrap: wrap; gap: 6px; }
256
- .tip { margin: 12px 0 0; color: var(--ink-soft); font-size: 13.5px; }
257
- .contract-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 16px; }
258
- .mini { border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; background: var(--panel); }
259
- .mini .micro { margin-bottom: 8px; }
260
- .kv { display: flex; justify-content: space-between; gap: 14px; padding: 4px 0; font-size: 13.5px; }
261
- .kv span:last-child { font-family: var(--font-mono); font-size: 11.5px; color: var(--ink-soft); text-align: right; }
262
- .steps { margin: 0; padding: 0; list-style: none; display: grid; gap: 6px; font-size: 13px; color: var(--ink-soft); }
263
- .steps b { color: var(--ink); font-family: var(--font-mono); font-size: 12px; font-weight: 600; }
264
- details.disclose { margin-top: 12px; border: 1px solid var(--line); border-radius: 10px; background: #fff; }
265
- details.disclose > summary {
266
- display: flex; align-items: center; gap: 8px; padding: 11px 14px; cursor: pointer; list-style: none;
267
- font-family: var(--font-mono); font-size: 12.5px;
268
- }
269
- details.disclose > summary::-webkit-details-marker { display: none; }
270
- details.disclose > summary::after { content: "+"; margin-left: auto; color: var(--ink-soft); }
271
- details.disclose[open] > summary::after { content: "−"; }
272
- details.disclose > .body { padding: 2px 14px 14px; border-top: 1px solid var(--line-soft); }
273
- .spec { margin-top: 12px; }
274
- .spec .micro { margin-bottom: 4px; }
275
- .spec p { margin: 0; font-size: 13px; color: var(--ink-soft); line-height: 1.6; }
276
- pre.block {
277
- margin: 4px 0 0; padding: 11px 13px; border-radius: 8px; border: 1px solid var(--line-soft);
278
- background: var(--panel); font-family: var(--font-mono); font-size: 12px; line-height: 1.6;
279
- white-space: pre-wrap; word-break: break-word; max-height: 320px; overflow: auto;
280
- }
281
- .seg { display: inline-flex; gap: 3px; padding: 3px; margin: 22px 0 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); }
282
- .seg button { padding: 6px 14px; border: 0; border-radius: 7px; background: transparent; font-family: var(--font-mono); font-size: 12.5px; color: var(--ink-soft); cursor: pointer; }
283
- .seg button.on { background: #fff; color: var(--ink); box-shadow: 0 1px 2px rgba(20, 23, 15, .1); }
284
- .seg-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
285
- .form { display: grid; gap: 18px; padding: 22px; }
286
- .field { display: grid; gap: 7px; }
287
- .field-label { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
288
- .req { color: var(--danger); font-weight: 700; }
289
- .opt { font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-soft); }
290
- .key-chip, .lang-chip { padding: 0 7px; border-radius: 5px; border: 1px solid var(--line); background: var(--panel); font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-soft); line-height: 1.8; }
291
- .lang-chip { border-color: var(--accent-soft-border); background: var(--accent-soft); color: var(--accent-strong); }
292
- .hint { font-size: 12.5px; color: var(--ink-soft); }
293
- .two { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
294
- .attest { display: grid; gap: 9px; padding: 14px 16px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); }
295
- .attest label { display: flex; align-items: flex-start; gap: 10px; font-size: 13.5px; cursor: pointer; }
296
- .attest input { width: 15px; height: 15px; margin-top: 2px; accent-color: var(--accent-strong); }
297
- .stage-bar { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-radius: 10px; background: var(--ink); color: #dfe5d8; font-family: var(--font-mono); font-size: 12px; }
298
- .stage-bar .i { color: var(--lime); }
299
- .form-foot { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-top: 4px; border-top: 1px solid var(--line-soft); }
300
- .dropzone {
301
- display: grid; place-items: center; gap: 6px; padding: 28px; margin-top: 12px;
302
- border: 1.5px dashed var(--line-strong); border-radius: 12px; background: var(--panel);
303
- color: var(--ink-soft); font-family: var(--font-mono); font-size: 12.5px; cursor: pointer; text-align: center;
304
- }
305
- .dropzone:hover, .dropzone.over { border-color: var(--ink); color: var(--ink); }
306
- .dropzone input { display: none; }
307
- table.list { width: 100%; border-collapse: collapse; font-size: 13px; }
308
- table.list th { padding: 10px 14px; text-align: left; font-family: var(--font-mono); font-size: 10px; font-weight: 500; letter-spacing: .07em; text-transform: uppercase; color: var(--ink-soft); border-bottom: 1px solid var(--line); background: var(--panel); }
309
- table.list td { padding: 11px 14px; border-bottom: 1px solid var(--line-soft); vertical-align: middle; }
310
- table.list tr:last-child td { border-bottom: 0; }
311
- table.list tr.click { cursor: pointer; }
312
- table.list tr.click:hover td { background: var(--panel); }
313
- .table-wrap { overflow: auto; max-height: 420px; border: 1px solid var(--line); border-radius: 12px; background: #fff; }
314
- .ok-text { color: var(--success); } .bad-text { color: var(--danger); }
315
-
316
- /* --- Notices, checks, toast ----------------------------------------------------------------------------------- */
317
- .notice { padding: 12px 16px; border-radius: 10px; border: 1px solid var(--line); background: var(--panel); font-size: 13.5px; }
318
- .notice b { font-family: var(--font-mono); font-size: 12.5px; }
319
- .notice-ok { background: var(--success-soft); border-color: var(--success-border); }
320
- .notice-warn { background: var(--warn-soft); border-color: var(--warn-border); }
321
- .notice-bad { background: var(--danger-soft); border-color: var(--danger-border); }
322
- .checks { list-style: none; margin: 16px 0 0; padding: 0; display: grid; gap: 4px; }
323
- .check { display: flex; gap: 12px; align-items: flex-start; padding: 10px 0; border-top: 1px solid var(--line-soft); }
324
- .check:first-child { border-top: 0; }
325
- .check-icon { width: 22px; height: 22px; flex: none; border-radius: 50%; display: grid; place-items: center; }
326
- .check-icon .i { width: 12px; height: 12px; }
327
- .check-pass .check-icon { background: var(--success-soft); color: var(--success); }
328
- .check-fail .check-icon { background: var(--danger-soft); color: var(--danger); }
329
- .check-wait .check-icon { background: var(--line-soft); color: var(--ink-soft); }
330
- .check-note .check-icon { background: var(--warn-soft); color: var(--warn); }
331
- .check b { display: block; font-size: 13.5px; font-weight: 600; }
332
- .check span { font-size: 12.5px; color: var(--ink-soft); }
333
- .toast {
334
- position: fixed; right: 20px; bottom: 20px; z-index: 50; max-width: 380px;
335
- padding: 12px 16px; border-radius: 10px; background: var(--ink); color: #fff; font-size: 13px;
336
- box-shadow: 0 10px 30px rgba(0, 0, 0, .18);
337
- }
338
- .skeleton { height: 92px; border-radius: 14px; background: linear-gradient(90deg, #fff 0%, #f6f7f2 50%, #fff 100%); background-size: 200% 100%; border: 1px solid var(--line); animation: shimmer 1.2s infinite; }
339
- @keyframes shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }
340
-
341
- /* --- Other pages ------------------------------------------------------------------------------------------------ */
342
- .tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; }
343
- .tile { padding: 16px 18px; }
344
- .tile b { display: block; margin-top: 6px; font-family: var(--font-mono); font-size: 22px; font-weight: 600; }
345
- .tiers { display: flex; flex-wrap: wrap; gap: 8px; }
346
- .how { margin: 0; padding: 0; list-style: none; counter-reset: how; display: grid; gap: 10px; }
347
- .how li { counter-increment: how; display: grid; grid-template-columns: 30px 1fr; gap: 12px; align-items: start; font-size: 14px; }
348
- .how li::before { content: counter(how); width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center; background: var(--ink); color: var(--lime); font-family: var(--font-mono); font-size: 12px; font-weight: 600; }
349
- .code-box { position: relative; }
350
- .code-box pre.block { padding-right: 76px; }
351
- .code-box .btn { position: absolute; right: 8px; top: 8px; }
352
- .profile-head { display: flex; align-items: center; gap: 16px; }
353
- .profile-head .avatar { width: 52px; height: 52px; font-size: 18px; background: var(--ink); color: var(--lime); border: 0; }
354
-
355
- /* --- Reviewing ------------------------------------------------------------------------------------------------- */
356
- .btn-danger { background: var(--danger); border-color: var(--danger); color: #fff; }
357
- .btn-danger:hover { background: #a82a42; border-color: #a82a42; }
358
- .pool-row.has-action { padding-right: 104px; }
359
- .row-action { position: absolute; right: 18px; top: 15px; }
360
- .audit-grid { display: grid; grid-template-columns: 230px minmax(0, 1fr); gap: 14px; margin-top: 14px; align-items: start; }
361
- .item-list { display: grid; gap: 2px; padding: 8px; position: sticky; top: 16px; max-height: calc(100vh - 32px); overflow: auto; }
362
- .item-list button {
363
- display: flex; align-items: center; gap: 9px; width: 100%; padding: 8px 9px; border: 0; border-radius: 8px;
364
- background: none; color: var(--ink-soft); font-size: 13px; text-align: left; cursor: pointer;
365
- }
366
- .item-list button:hover { background: var(--panel); color: var(--ink); }
367
- .item-list button.on { background: var(--nav-active); color: var(--lime); }
368
- .item-list .t { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
369
- .mark { width: 22px; height: 22px; flex: none; border-radius: 50%; display: grid; place-items: center; background: var(--line-soft); color: var(--ink-soft); font-family: var(--font-mono); font-size: 10.5px; }
370
- .mark .i { width: 12px; height: 12px; }
371
- .mark.ok { background: var(--success-soft); color: var(--success); }
372
- .mark.flagged { background: var(--danger-soft); color: var(--danger); }
373
- .item-card { display: grid; gap: 16px; padding: 22px; }
374
- .item-card h2 { margin: 0; font-size: 17px; font-weight: 600; }
375
- .item-card pre.block { margin-top: 6px; }
376
- .files { list-style: none; margin: 6px 0 8px; padding: 0; display: grid; gap: 4px; font-size: 13px; }
377
- .files li { display: flex; align-items: center; gap: 7px; }
378
- .decide { display: grid; gap: 14px; padding: 16px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); }
379
- .decide .reject { display: grid; gap: 8px; padding-top: 14px; border-top: 1px solid var(--line); }
380
- .item-nav { display: flex; justify-content: space-between; gap: 8px; }
381
- .sponsor-card { margin-top: 14px; padding: 20px 22px; border-color: var(--karma-border); }
382
- .sponsor-card details.disclose > summary { gap: 10px; }
383
-
384
- /* --- Small screens ------------------------------------------------------------------------------------------------ */
385
- @media (max-width: 600px) { .hide-sm { display: none; } }
386
- @media (max-width: 900px) {
387
- .shell { grid-template-columns: minmax(0, 1fr); }
388
- .sidebar { position: sticky; top: 0; z-index: 20; min-width: 0; height: auto; flex-direction: row; align-items: center; border-right: 0; border-bottom: 1px solid var(--dark-line); }
389
- .brand { border-bottom: 0; padding: 12px 14px; }
390
- .brand span { font-size: 15px; }
391
- .nav { display: flex; overflow-x: auto; padding: 8px; gap: 2px; flex: 1; min-width: 0; scrollbar-width: none; }
392
- .nav a { padding: 8px 10px; font-size: 12px; white-space: nowrap; }
393
- .nav a .i { display: none; }
394
- .side-bottom { display: none; }
395
- .top-action {
396
- display: inline-flex; align-items: center; flex: none; margin-right: 10px; padding: 7px 10px;
397
- border: 1px solid var(--dark-line-soft); border-radius: 8px; background: none; cursor: pointer;
398
- color: var(--lime); font-family: var(--font-mono); font-size: 12px;
399
- }
400
- .page { padding: 22px 16px 60px; }
401
- .karma-card { grid-template-columns: auto 1fr; }
402
- .karma-next { grid-column: 1 / -1; }
403
- .stats { grid-template-columns: repeat(2, 1fr); }
404
- .stat:nth-child(3) { border-left: 0; }
405
- .filters { grid-template-columns: 1fr 1fr; }
406
- .filters .search { grid-column: 1 / -1; }
407
- .signin-card { grid-template-columns: auto 1fr; }
408
- .signin-actions { grid-column: 1 / -1; justify-items: stretch; }
409
- .signin-actions .note { text-align: left; max-width: none; }
410
- .contract-grid, .two { grid-template-columns: 1fr; }
411
- .pool-row .cap { grid-template-columns: 1fr auto; }
412
- .pool-row .cap .progress { grid-column: 1 / -1; grid-row: 2; }
413
- h1.title { font-size: 20px; }
414
- .kv { flex-direction: column; gap: 0; }
415
- .kv span:last-child { text-align: left; }
416
- .form, .contract { padding: 18px 16px; }
417
- .form-foot { flex-direction: column; align-items: stretch; }
418
- .form-foot .btn { width: 100%; }
419
- .audit-grid { grid-template-columns: minmax(0, 1fr); }
420
- .item-list { position: static; max-height: none; display: flex; overflow-x: auto; }
421
- .item-list button { width: auto; flex: none; }
422
- .item-list .t { display: none; }
423
- .item-card { padding: 18px 16px; }
424
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* SPDX-License-Identifier: Apache-2.0 */
2
+ /* The DataBounty console's look, for the Space. Tokens are the console's own
3
+ (apps/web/app/globals.css): ink on paper, white cards, lime accent, a dark sidebar. */
4
+
5
+ :root {
6
+ --ink: #14170f;
7
+ --ink-soft: #6b7360;
8
+ --paper: #f3f4f0;
9
+ --panel: #f7f8f4;
10
+ --line: #e4e6df;
11
+ --line-soft: #eef0e8;
12
+ --line-strong: #868d80;
13
+ --lime: #a3f60a;
14
+ --lime-ink: #4f7a00;
15
+ --dark: #0a0c0a;
16
+ --dark-line: #1c2a19;
17
+ --dark-line-soft: #2a3a26;
18
+ --dark-text: #eef2ec;
19
+ --dark-muted: #a4ad9c;
20
+ --dark-soft: #8a9382;
21
+ --dark-track: #152012;
22
+ --dark-nav-hover: #12160f;
23
+ --nav-active: #1a2810;
24
+ --karma: #7c5cc4;
25
+ --karma-soft: #f0ebfa;
26
+ --karma-border: #e2d8f6;
27
+ --accent-strong: #5f7a1e;
28
+ --accent-strong-hover: #4d6418;
29
+ --accent-soft: #f2f7e4;
30
+ --accent-soft-border: #d5deba;
31
+ --success: #1f8a5b;
32
+ --success-soft: #e6f5ec;
33
+ --success-border: #bfe6cd;
34
+ --danger: #c2334d;
35
+ --danger-soft: #fbe7ea;
36
+ --danger-border: #f2c4cd;
37
+ --warn: #8a6412;
38
+ --warn-soft: #fbf6e9;
39
+ --warn-border: #eadbb5;
40
+ --info: #2f5f96;
41
+ --info-soft: #eef4fb;
42
+ --info-border: #d4e3f5;
43
+ --font-sans: "Geist", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
44
+ --font-mono: "Geist Mono", ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
45
+ }
46
+
47
+ * { box-sizing: border-box; }
48
+ html, body { margin: 0; }
49
+ body {
50
+ background: var(--paper);
51
+ color: var(--ink);
52
+ font-family: var(--font-sans);
53
+ font-size: 14px;
54
+ line-height: 1.5;
55
+ font-feature-settings: "tnum" 1;
56
+ -webkit-font-smoothing: antialiased;
57
+ }
58
+ a { color: inherit; }
59
+ a.plain { text-decoration: none; }
60
+ a.plain:hover { text-decoration: underline; text-underline-offset: 3px; }
61
+ a.pill.plain:hover { text-decoration: none; border-color: var(--karma); }
62
+ button, input, select, textarea { font: inherit; color: inherit; }
63
+ ::selection { background: var(--lime); color: var(--dark); }
64
+ [hidden] { display: none !important; }
65
+ .i { width: 15px; height: 15px; flex: none; vertical-align: -2px; }
66
+
67
+ /* --- Shell ----------------------------------------------------------------------------------- */
68
+ .shell { display: grid; grid-template-columns: 240px minmax(0, 1fr); min-height: 100vh; }
69
+ .sidebar {
70
+ position: sticky; top: 0; height: 100vh;
71
+ display: flex; flex-direction: column;
72
+ background: var(--dark); color: var(--dark-text);
73
+ border-right: 1px solid var(--dark-line);
74
+ }
75
+ .brand {
76
+ display: flex; align-items: center; gap: 9px;
77
+ padding: 20px 20px 19px; border-bottom: 1px solid var(--dark-line);
78
+ text-decoration: none;
79
+ }
80
+ .brand img { width: 22px; height: 22px; }
81
+ .brand span { font-family: var(--font-mono); font-weight: 700; font-size: 17px; letter-spacing: -0.03em; color: #fff; }
82
+ .brand span b { color: var(--lime); font-weight: 700; }
83
+ .nav { display: grid; gap: 3px; padding: 16px 12px; }
84
+ .nav a {
85
+ display: flex; align-items: center; gap: 12px;
86
+ padding: 10px 12px; border-radius: 8px;
87
+ font-family: var(--font-mono); font-size: 13px; color: var(--dark-muted); text-decoration: none;
88
+ transition: background .12s, color .12s;
89
+ }
90
+ .nav a:hover { background: var(--dark-nav-hover); color: var(--dark-text); }
91
+ .nav a.active { background: var(--nav-active); color: var(--lime); }
92
+ .nav .badge { margin-left: auto; min-width: 18px; padding: 0 6px; border-radius: 999px; background: var(--lime); color: var(--dark); font-size: 10px; font-weight: 700; line-height: 18px; text-align: center; }
93
+ .side-bottom { margin-top: auto; padding: 12px; border-top: 1px solid var(--dark-line); display: grid; gap: 8px; }
94
+ .side-karma { border: 1px solid var(--dark-line-soft); border-radius: 10px; padding: 11px 13px; background: #0c100b; }
95
+ .side-karma .row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
96
+ .micro-dark { font-family: var(--font-mono); font-size: 10px; letter-spacing: .07em; text-transform: uppercase; color: var(--dark-soft); }
97
+ .side-karma .pts { font-family: var(--font-mono); font-weight: 600; font-size: 14px; color: #fff; }
98
+ .side-karma .to { font-family: var(--font-mono); font-size: 11px; color: var(--dark-soft); }
99
+ .chip-dark { font-family: var(--font-mono); font-size: 10px; padding: 1px 7px; border-radius: 999px; background: #1f2a17; color: var(--dark-muted); }
100
+ .bar-dark { height: 3px; margin-top: 9px; border-radius: 999px; background: var(--dark-track); overflow: hidden; }
101
+ .bar-dark span { display: block; height: 100%; background: var(--lime); }
102
+ .user-chip { display: flex; align-items: center; gap: 10px; padding: 6px 4px; }
103
+ .avatar {
104
+ width: 32px; height: 32px; flex: none; border-radius: 50%;
105
+ display: grid; place-items: center;
106
+ background: #161b13; border: 1px solid var(--dark-line-soft);
107
+ font-family: var(--font-mono); font-weight: 600; font-size: 13px; color: #fff;
108
+ }
109
+ .user-chip .who { min-width: 0; }
110
+ .user-chip b { display: block; font-family: var(--font-mono); font-size: 12.5px; font-weight: 600; color: #fff; }
111
+ .user-chip span { display: block; font-family: var(--font-mono); font-size: 11px; color: var(--dark-soft); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
112
+ .side-link { border: 0; background: none; padding: 4px; color: var(--dark-soft); font-family: var(--font-mono); font-size: 11.5px; text-align: left; cursor: pointer; }
113
+ .side-link:hover { color: var(--lime); }
114
+ .side-signin { display: grid; gap: 8px; padding: 4px; }
115
+ .side-signin p { margin: 0; font-size: 12px; color: var(--dark-soft); line-height: 1.45; }
116
+ .top-action { display: none; }
117
+
118
+ .main { min-width: 0; }
119
+ .page { max-width: 1120px; padding: 34px 40px 72px; animation: fade .18s ease-out; }
120
+ @keyframes fade { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
121
+ @media (prefers-reduced-motion: reduce) { .page { animation: none; } }
122
+
123
+ /* --- Type ------------------------------------------------------------------------------------- */
124
+ .page-head { margin-bottom: 22px; }
125
+ h1.title { margin: 0 0 6px; font-family: var(--font-mono); font-weight: 600; font-size: 23px; letter-spacing: -0.02em; overflow-wrap: anywhere; }
126
+ .sub { margin: 0; color: var(--ink-soft); font-size: 14px; }
127
+ h2.section { margin: 34px 0 4px; font-family: var(--font-mono); font-weight: 600; font-size: 16px; letter-spacing: -0.01em; }
128
+ .section-sub { margin: 0 0 14px; color: var(--ink-soft); font-size: 13px; }
129
+ .micro { font-family: var(--font-mono); font-size: 10px; letter-spacing: .07em; text-transform: uppercase; color: var(--ink-soft); }
130
+ .mono { font-family: var(--font-mono); }
131
+ .small { font-size: 11.5px; }
132
+ .muted { color: var(--ink-soft); }
133
+ .strong { font-weight: 600; }
134
+ .karma-text { color: var(--karma); }
135
+ .back { display: inline-flex; align-items: center; gap: 6px; margin-bottom: 14px; font-family: var(--font-mono); font-size: 12px; color: var(--ink-soft); text-decoration: none; }
136
+ .back:hover { color: var(--ink); }
137
+
138
+ /* --- Surfaces ----------------------------------------------------------------------------------- */
139
+ .card { background: #fff; border: 1px solid var(--line); border-radius: 14px; }
140
+ .pad { padding: 20px 22px; }
141
+ .stack { display: grid; gap: 12px; }
142
+ .row { display: flex; align-items: center; gap: 10px; }
143
+ .between { justify-content: space-between; }
144
+ .wrap { flex-wrap: wrap; }
145
+ .grow { flex: 1; min-width: 0; }
146
+
147
+ .pill {
148
+ display: inline-flex; align-items: center; gap: 5px;
149
+ padding: 1px 9px; border-radius: 999px; border: 1px solid var(--line);
150
+ background: var(--panel); color: var(--ink-soft);
151
+ font-family: var(--font-mono); font-size: 11px; line-height: 1.7; white-space: nowrap;
152
+ }
153
+ .pill-karma, .pill-review { background: var(--karma-soft); border-color: var(--karma-border); color: var(--karma); }
154
+ .pill-ok { background: var(--success-soft); border-color: var(--success-border); color: var(--success); }
155
+ .pill-warn { background: var(--warn-soft); border-color: var(--warn-border); color: var(--warn); }
156
+ .pill-bad { background: var(--danger-soft); border-color: var(--danger-border); color: var(--danger); }
157
+ .pill-info { background: var(--info-soft); border-color: var(--info-border); color: var(--info); }
158
+ .pill-lime { background: var(--accent-soft); border-color: var(--accent-soft-border); color: var(--accent-strong); }
159
+ .dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; display: inline-block; }
160
+
161
+ .btn {
162
+ display: inline-flex; align-items: center; justify-content: center; gap: 8px;
163
+ padding: 9px 16px; border-radius: 8px; border: 1px solid var(--line-strong);
164
+ background: #fff; color: var(--ink); text-decoration: none; cursor: pointer;
165
+ font-family: var(--font-mono); font-size: 12.5px; font-weight: 500; white-space: nowrap;
166
+ transition: background .12s, border-color .12s, opacity .12s;
167
+ }
168
+ .btn:hover { border-color: var(--ink); }
169
+ .btn-primary { background: var(--ink); border-color: var(--ink); color: var(--lime); }
170
+ .btn-primary:hover { background: #000; border-color: #000; }
171
+ .btn-submit { background: var(--accent-strong); border-color: var(--accent-strong); color: #fff; }
172
+ .btn-submit:hover { background: var(--accent-strong-hover); border-color: var(--accent-strong-hover); }
173
+ .btn-lime { background: var(--lime); border-color: var(--lime); color: var(--dark); }
174
+ .btn-lime:hover { background: #b8ff3d; border-color: #b8ff3d; }
175
+ .btn-sm { padding: 6px 11px; font-size: 11.5px; }
176
+ .btn-wide { width: 100%; }
177
+ .btn[disabled], .btn[aria-disabled="true"] { opacity: .45; cursor: not-allowed; pointer-events: none; }
178
+
179
+ .input, .select, .textarea {
180
+ width: 100%; padding: 10px 12px; border-radius: 8px;
181
+ border: 1px solid var(--line); background: #fff; outline: none;
182
+ font-size: 14px; transition: border-color .12s, box-shadow .12s;
183
+ }
184
+ .input::placeholder, .textarea::placeholder { font-family: var(--font-mono); font-size: 12.5px; color: #9aa08d; }
185
+ .input:focus, .select:focus, .textarea:focus { border-color: var(--ink); box-shadow: 0 0 0 3px rgba(20, 23, 15, .06); }
186
+ .select { appearance: none; padding-right: 34px; font-family: var(--font-mono); font-size: 12.5px; cursor: pointer;
187
+ background: #fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b7360' stroke-width='2'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E") no-repeat right 12px center; }
188
+ .textarea { resize: vertical; min-height: 92px; }
189
+ .textarea.code {
190
+ min-height: 170px; background: var(--panel); tab-size: 4;
191
+ font-family: var(--font-mono); font-size: 12.5px; line-height: 1.6; white-space: pre;
192
+ }
193
+ .search { position: relative; }
194
+ .search .i { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--ink-soft); }
195
+ .search .input { padding-left: 34px; font-family: var(--font-mono); font-size: 12.5px; }
196
+
197
+ .progress { height: 4px; border-radius: 999px; background: var(--line-soft); overflow: hidden; }
198
+ .progress span { display: block; height: 100%; min-width: 3px; border-radius: inherit; background: var(--ink); }
199
+
200
+ /* --- Workspace ------------------------------------------------------------------------------------- */
201
+ .karma-card { display: grid; grid-template-columns: auto auto 1fr auto; align-items: center; gap: 22px; padding: 20px 22px; }
202
+ .karma-icon { width: 42px; height: 42px; border-radius: 10px; display: grid; place-items: center; background: var(--karma-soft); color: var(--karma); }
203
+ .karma-icon .i { width: 19px; height: 19px; }
204
+ .karma-num { display: flex; align-items: baseline; gap: 7px; }
205
+ .karma-num b { font-family: var(--font-mono); font-size: 26px; font-weight: 600; line-height: 1; }
206
+ .chip { padding: 1px 8px; border-radius: 6px; background: var(--line-soft); font-family: var(--font-mono); font-size: 11px; color: var(--ink); }
207
+ .karma-next .row { justify-content: space-between; margin-bottom: 7px; }
208
+ .stats { display: grid; grid-template-columns: repeat(4, 1fr); margin-top: 14px; overflow: hidden; }
209
+ .stat { padding: 14px 16px; border-left: 1px solid var(--line); border-bottom: 2px solid transparent; }
210
+ .stat:first-child { border-left: 0; }
211
+ .stat .micro { display: flex; align-items: center; gap: 6px; }
212
+ .stat .micro .i { width: 12px; height: 12px; }
213
+ .stat b { display: block; margin-top: 6px; font-family: var(--font-mono); font-size: 21px; font-weight: 600; line-height: 1.1; }
214
+ .stat-ok { border-bottom-color: #8fbb3a; } .stat-ok b { color: var(--accent-strong); }
215
+ .stat-warn { border-bottom-color: #d9a43a; } .stat-warn b { color: var(--warn); }
216
+
217
+ .signin-card { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 20px; padding: 22px 24px; }
218
+ .signin-card img { width: 40px; height: 40px; }
219
+ .signin-card h2 { margin: 0 0 3px; font-family: var(--font-mono); font-size: 16px; font-weight: 600; }
220
+ .signin-card p { margin: 0; color: var(--ink-soft); font-size: 13.5px; }
221
+ .signin-actions { display: grid; gap: 6px; justify-items: end; }
222
+ .signin-actions .note { font-size: 11.5px; color: var(--ink-soft); max-width: 260px; text-align: right; }
223
+ .setup-card { padding: 20px 22px; }
224
+ .setup-card h2 { margin: 0 0 4px; font-family: var(--font-mono); font-size: 16px; font-weight: 600; }
225
+ .setup-card p { margin: 0 0 14px; color: var(--ink-soft); font-size: 13.5px; }
226
+ .setup-card .row .input { max-width: 280px; }
227
+
228
+ .filters { display: grid; grid-template-columns: minmax(200px, 320px) 200px 1fr; gap: 8px; align-items: center; margin-bottom: 12px; }
229
+ .filters .count { justify-self: end; font-family: var(--font-mono); font-size: 11.5px; color: var(--ink-soft); }
230
+ .rows { display: grid; gap: 12px; }
231
+ .pool-row, .sub-row {
232
+ position: relative; display: grid; gap: 9px; padding: 18px 50px 18px 20px;
233
+ text-decoration: none; transition: border-color .12s, box-shadow .12s;
234
+ }
235
+ .pool-row:hover, .sub-row:hover { border-color: #cfd3c6; box-shadow: 0 1px 2px rgba(20, 23, 15, .04); }
236
+ .pool-row .head, .sub-row .head { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; }
237
+ .pool-title, .sub-title { font-weight: 600; font-size: 14.5px; }
238
+ .pool-row .cap { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 12px; }
239
+ .pool-row .cap .progress { height: 3px; }
240
+ .row-arrow { position: absolute; right: 20px; top: 20px; color: var(--ink-soft); }
241
+ .tabs-pills { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
242
+ .tabs-pills button {
243
+ padding: 5px 13px; border-radius: 999px; border: 1px solid var(--line); background: #fff;
244
+ font-family: var(--font-mono); font-size: 12px; color: var(--ink-soft); cursor: pointer;
245
+ }
246
+ .tabs-pills button.on { background: var(--ink); border-color: var(--ink); color: var(--lime); }
247
+ .empty { padding: 26px; text-align: center; color: var(--ink-soft); font-size: 13.5px; }
248
+ .btn.more { justify-self: center; margin-top: 2px; }
249
+
250
+ /* --- Pool page ----------------------------------------------------------------------------------------- */
251
+ .cap-card { padding: 18px 22px; }
252
+ .cap-card .row { justify-content: space-between; flex-wrap: wrap; }
253
+ .contract { margin-top: 14px; padding: 22px; }
254
+ .contract h3 { margin: 4px 0 10px; font-size: 16px; font-weight: 600; }
255
+ .chips { display: flex; flex-wrap: wrap; gap: 6px; }
256
+ .tip { margin: 12px 0 0; color: var(--ink-soft); font-size: 13.5px; }
257
+ .contract-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 16px; }
258
+ .mini { border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; background: var(--panel); }
259
+ .mini .micro { margin-bottom: 8px; }
260
+ .kv { display: flex; justify-content: space-between; gap: 14px; padding: 4px 0; font-size: 13.5px; }
261
+ .kv span:last-child { font-family: var(--font-mono); font-size: 11.5px; color: var(--ink-soft); text-align: right; }
262
+ .steps { margin: 0; padding: 0; list-style: none; display: grid; gap: 6px; font-size: 13px; color: var(--ink-soft); }
263
+ .steps b { color: var(--ink); font-family: var(--font-mono); font-size: 12px; font-weight: 600; }
264
+ details.disclose { margin-top: 12px; border: 1px solid var(--line); border-radius: 10px; background: #fff; }
265
+ details.disclose > summary {
266
+ display: flex; align-items: center; gap: 8px; padding: 11px 14px; cursor: pointer; list-style: none;
267
+ font-family: var(--font-mono); font-size: 12.5px;
268
+ }
269
+ details.disclose > summary::-webkit-details-marker { display: none; }
270
+ details.disclose > summary::after { content: "+"; margin-left: auto; color: var(--ink-soft); }
271
+ details.disclose[open] > summary::after { content: "−"; }
272
+ details.disclose > .body { padding: 2px 14px 14px; border-top: 1px solid var(--line-soft); }
273
+ .spec { margin-top: 12px; }
274
+ .spec .micro { margin-bottom: 4px; }
275
+ .spec p { margin: 0; font-size: 13px; color: var(--ink-soft); line-height: 1.6; }
276
+ pre.block {
277
+ margin: 4px 0 0; padding: 11px 13px; border-radius: 8px; border: 1px solid var(--line-soft);
278
+ background: var(--panel); font-family: var(--font-mono); font-size: 12px; line-height: 1.6;
279
+ white-space: pre-wrap; word-break: break-word; max-height: 320px; overflow: auto;
280
+ }
281
+ .seg { display: inline-flex; gap: 3px; padding: 3px; margin: 22px 0 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); }
282
+ .seg button { padding: 6px 14px; border: 0; border-radius: 7px; background: transparent; font-family: var(--font-mono); font-size: 12.5px; color: var(--ink-soft); cursor: pointer; }
283
+ .seg button.on { background: #fff; color: var(--ink); box-shadow: 0 1px 2px rgba(20, 23, 15, .1); }
284
+ .seg-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
285
+ .form { display: grid; gap: 18px; padding: 22px; }
286
+ .field { display: grid; gap: 7px; }
287
+ .field-label { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
288
+ .req { color: var(--danger); font-weight: 700; }
289
+ .opt { font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-soft); }
290
+ .key-chip, .lang-chip { padding: 0 7px; border-radius: 5px; border: 1px solid var(--line); background: var(--panel); font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-soft); line-height: 1.8; }
291
+ .lang-chip { border-color: var(--accent-soft-border); background: var(--accent-soft); color: var(--accent-strong); }
292
+ .hint { font-size: 12.5px; color: var(--ink-soft); }
293
+ .two { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
294
+ .attest { display: grid; gap: 9px; padding: 14px 16px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); }
295
+ .attest label { display: flex; align-items: flex-start; gap: 10px; font-size: 13.5px; cursor: pointer; }
296
+ .attest input { width: 15px; height: 15px; margin-top: 2px; accent-color: var(--accent-strong); }
297
+ .stage-bar { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-radius: 10px; background: var(--ink); color: #dfe5d8; font-family: var(--font-mono); font-size: 12px; }
298
+ .stage-bar .i { color: var(--lime); }
299
+ .form-foot { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-top: 4px; border-top: 1px solid var(--line-soft); }
300
+ .dropzone {
301
+ display: grid; place-items: center; gap: 6px; padding: 28px; margin-top: 12px;
302
+ border: 1.5px dashed var(--line-strong); border-radius: 12px; background: var(--panel);
303
+ color: var(--ink-soft); font-family: var(--font-mono); font-size: 12.5px; cursor: pointer; text-align: center;
304
+ }
305
+ .dropzone:hover, .dropzone.over { border-color: var(--ink); color: var(--ink); }
306
+ .dropzone input { display: none; }
307
+ table.list { width: 100%; border-collapse: collapse; font-size: 13px; }
308
+ table.list th { padding: 10px 14px; text-align: left; font-family: var(--font-mono); font-size: 10px; font-weight: 500; letter-spacing: .07em; text-transform: uppercase; color: var(--ink-soft); border-bottom: 1px solid var(--line); background: var(--panel); }
309
+ table.list td { padding: 11px 14px; border-bottom: 1px solid var(--line-soft); vertical-align: middle; }
310
+ table.list tr:last-child td { border-bottom: 0; }
311
+ table.list tr.click { cursor: pointer; }
312
+ table.list tr.click:hover td { background: var(--panel); }
313
+ .table-wrap { overflow: auto; max-height: 420px; border: 1px solid var(--line); border-radius: 12px; background: #fff; }
314
+ .ok-text { color: var(--success); } .bad-text { color: var(--danger); }
315
+
316
+ /* --- Notices, checks, toast ----------------------------------------------------------------------------------- */
317
+ .notice { padding: 12px 16px; border-radius: 10px; border: 1px solid var(--line); background: var(--panel); font-size: 13.5px; }
318
+ .notice b { font-family: var(--font-mono); font-size: 12.5px; }
319
+ .notice-ok { background: var(--success-soft); border-color: var(--success-border); }
320
+ .notice-warn { background: var(--warn-soft); border-color: var(--warn-border); }
321
+ .notice-bad { background: var(--danger-soft); border-color: var(--danger-border); }
322
+ .checks { list-style: none; margin: 16px 0 0; padding: 0; display: grid; gap: 4px; }
323
+ .check { display: flex; gap: 12px; align-items: flex-start; padding: 10px 0; border-top: 1px solid var(--line-soft); }
324
+ .check:first-child { border-top: 0; }
325
+ .check-icon { width: 22px; height: 22px; flex: none; border-radius: 50%; display: grid; place-items: center; }
326
+ .check-icon .i { width: 12px; height: 12px; }
327
+ .check-pass .check-icon { background: var(--success-soft); color: var(--success); }
328
+ .check-fail .check-icon { background: var(--danger-soft); color: var(--danger); }
329
+ .check-wait .check-icon { background: var(--line-soft); color: var(--ink-soft); }
330
+ .check-note .check-icon { background: var(--warn-soft); color: var(--warn); }
331
+ .check b { display: block; font-size: 13.5px; font-weight: 600; }
332
+ .check span { font-size: 12.5px; color: var(--ink-soft); }
333
+ .toast {
334
+ position: fixed; right: 20px; bottom: 20px; z-index: 50; max-width: 380px;
335
+ padding: 12px 16px; border-radius: 10px; background: var(--ink); color: #fff; font-size: 13px;
336
+ box-shadow: 0 10px 30px rgba(0, 0, 0, .18);
337
+ }
338
+ .skeleton { height: 92px; border-radius: 14px; background: linear-gradient(90deg, #fff 0%, #f6f7f2 50%, #fff 100%); background-size: 200% 100%; border: 1px solid var(--line); animation: shimmer 1.2s infinite; }
339
+ @keyframes shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }
340
+
341
+ /* --- Other pages ------------------------------------------------------------------------------------------------ */
342
+ .tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; }
343
+ .tile { padding: 16px 18px; }
344
+ .tile b { display: block; margin-top: 6px; font-family: var(--font-mono); font-size: 22px; font-weight: 600; }
345
+ .tiers { display: flex; flex-wrap: wrap; gap: 8px; }
346
+ .how { margin: 0; padding: 0; list-style: none; counter-reset: how; display: grid; gap: 10px; }
347
+ .how li { counter-increment: how; display: grid; grid-template-columns: 30px 1fr; gap: 12px; align-items: start; font-size: 14px; }
348
+ .how li::before { content: counter(how); width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center; background: var(--ink); color: var(--lime); font-family: var(--font-mono); font-size: 12px; font-weight: 600; }
349
+ .code-box { position: relative; }
350
+ .code-box pre.block { padding-right: 76px; }
351
+ .code-box .btn { position: absolute; right: 8px; top: 8px; }
352
+ .profile-head { display: flex; align-items: center; gap: 16px; }
353
+ .profile-head .avatar { width: 52px; height: 52px; font-size: 18px; background: var(--ink); color: var(--lime); border: 0; }
354
+
355
+ /* --- Reviewing ------------------------------------------------------------------------------------------------- */
356
+ .btn-danger { background: var(--danger); border-color: var(--danger); color: #fff; }
357
+ .btn-danger:hover { background: #a82a42; border-color: #a82a42; }
358
+ .pool-row.has-action { padding-right: 104px; }
359
+ .row-action { position: absolute; right: 18px; top: 15px; }
360
+ .audit-grid { display: grid; grid-template-columns: 230px minmax(0, 1fr); gap: 14px; margin-top: 14px; align-items: start; }
361
+ .item-list { display: grid; gap: 2px; padding: 8px; position: sticky; top: 16px; max-height: calc(100vh - 32px); overflow: auto; }
362
+ .item-list button {
363
+ display: flex; align-items: center; gap: 9px; width: 100%; padding: 8px 9px; border: 0; border-radius: 8px;
364
+ background: none; color: var(--ink-soft); font-size: 13px; text-align: left; cursor: pointer;
365
+ }
366
+ .item-list button:hover { background: var(--panel); color: var(--ink); }
367
+ .item-list button.on { background: var(--nav-active); color: var(--lime); }
368
+ .item-list .t { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
369
+ .mark { width: 22px; height: 22px; flex: none; border-radius: 50%; display: grid; place-items: center; background: var(--line-soft); color: var(--ink-soft); font-family: var(--font-mono); font-size: 10.5px; }
370
+ .mark .i { width: 12px; height: 12px; }
371
+ .mark.ok { background: var(--success-soft); color: var(--success); }
372
+ .mark.flagged { background: var(--danger-soft); color: var(--danger); }
373
+ .item-card { display: grid; gap: 16px; padding: 22px; }
374
+ .item-card h2 { margin: 0; font-size: 17px; font-weight: 600; }
375
+ .item-card pre.block { margin-top: 6px; }
376
+ .files { list-style: none; margin: 6px 0 8px; padding: 0; display: grid; gap: 4px; font-size: 13px; }
377
+ .files li { display: flex; align-items: center; gap: 7px; }
378
+ .decide { display: grid; gap: 14px; padding: 16px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); }
379
+ .decide .reject { display: grid; gap: 8px; padding-top: 14px; border-top: 1px solid var(--line); }
380
+ .item-nav { display: flex; justify-content: space-between; gap: 8px; }
381
+ .sponsor-card { margin-top: 14px; padding: 20px 22px; border-color: var(--karma-border); }
382
+ .sponsor-card details.disclose > summary { gap: 10px; }
383
+
384
+ /* --- Small screens ------------------------------------------------------------------------------------------------ */
385
+ @media (max-width: 600px) { .hide-sm { display: none; } }
386
+ @media (max-width: 900px) {
387
+ .shell { grid-template-columns: minmax(0, 1fr); }
388
+ .sidebar { position: sticky; top: 0; z-index: 20; min-width: 0; height: auto; flex-direction: row; align-items: center; border-right: 0; border-bottom: 1px solid var(--dark-line); }
389
+ .brand { border-bottom: 0; padding: 12px 14px; }
390
+ .brand span { font-size: 15px; }
391
+ .nav { display: flex; overflow-x: auto; padding: 8px; gap: 2px; flex: 1; min-width: 0; scrollbar-width: none; }
392
+ .nav a { padding: 8px 10px; font-size: 12px; white-space: nowrap; }
393
+ .nav a .i { display: none; }
394
+ .side-bottom { display: none; }
395
+ .top-action {
396
+ display: inline-flex; align-items: center; flex: none; margin-right: 10px; padding: 7px 10px;
397
+ border: 1px solid var(--dark-line-soft); border-radius: 8px; background: none; cursor: pointer;
398
+ color: var(--lime); font-family: var(--font-mono); font-size: 12px;
399
+ }
400
+ .page { padding: 22px 16px 60px; }
401
+ .karma-card { grid-template-columns: auto 1fr; }
402
+ .karma-next { grid-column: 1 / -1; }
403
+ .stats { grid-template-columns: repeat(2, 1fr); }
404
+ .stat:nth-child(3) { border-left: 0; }
405
+ .filters { grid-template-columns: 1fr 1fr; }
406
+ .filters .search { grid-column: 1 / -1; }
407
+ .signin-card { grid-template-columns: auto 1fr; }
408
+ .signin-actions { grid-column: 1 / -1; justify-items: stretch; }
409
+ .signin-actions .note { text-align: left; max-width: none; }
410
+ .contract-grid, .two { grid-template-columns: 1fr; }
411
+ .pool-row .cap { grid-template-columns: 1fr auto; }
412
+ .pool-row .cap .progress { grid-column: 1 / -1; grid-row: 2; }
413
+ h1.title { font-size: 20px; }
414
+ .kv { flex-direction: column; gap: 0; }
415
+ .kv span:last-child { text-align: left; }
416
+ .form, .contract { padding: 18px 16px; }
417
+ .form-foot { flex-direction: column; align-items: stretch; }
418
+ .form-foot .btn { width: 100%; }
419
+ .audit-grid { grid-template-columns: minmax(0, 1fr); }
420
+ .item-list { position: static; max-height: none; display: flex; overflow-x: auto; }
421
+ .item-list button { width: auto; flex: none; }
422
+ .item-list .t { display: none; }
423
+ .item-card { padding: 18px 16px; }
424
+ }
425
+
426
+ /* Requesting a dataset (Sponsor) */
427
+ .request-card { display: grid; gap: 10px; padding: 16px 18px; }
428
+ .request-card .head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
429
+ .request-card .head .pool-title { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; }
430
+ .request-card .row { gap: 8px; }
431
+ .admin-note { border-left: 3px solid var(--warn-border); background: var(--warn-soft); border-radius: 0 8px 8px 0; padding: 8px 12px; }
432
+ .admin-note p { margin: 4px 0 0; font-size: 13px; }
433
+ .sample-line { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
434
+ .slots { display: inline-flex; gap: 5px; }
435
+ .slot { width: 10px; height: 10px; border-radius: 50%; border: 1.5px solid var(--line-strong); background: transparent; }
436
+ .slot-ok { background: var(--success); border-color: var(--success); }
437
+ .slot-wait { background: var(--warn-soft); border-color: var(--warn); }
438
+ label.btn input[type=file] { display: none; }
439
+ label.btn.busy { opacity: 0.6; pointer-events: none; }
web/app.js CHANGED
@@ -1138,15 +1138,57 @@ function drawSponsorPanel(c, data, filter, live) {
1138
  }
1139
 
1140
  // --- Sponsoring ----------------------------------------------------------------------------------------
1141
- // Requesting a dataset happens on databounty.io: DataBounty's API doesn't offer it to other apps.
 
 
 
 
 
 
 
 
 
 
 
 
 
1142
 
1143
  async function pageSponsor() {
1144
  const live = ticket();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1145
  const data = await overview();
1146
  if (!live()) return;
1147
  const app = safeUrl(data.appUrl || "https://console.databounty.io");
1148
  mount($("#page"), html`
1149
- <header class="page-head"><h1 class="title">Sponsor a dataset</h1><p class="sub">Ask the community for the dataset you need. Contributors build it, validators check it, and it's published openly.</p></header>
1150
  <div class="card signin-card">
1151
  <img src="/static/logo.svg" alt="">
1152
  <div><h2>Request a dataset</h2><p>Describe what each item needs, how many you want and how hard they should be. Requests are made on databounty.io.</p></div>
@@ -1168,6 +1210,179 @@ async function pageSponsor() {
1168
  </div>`);
1169
  }
1170
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1171
  // --- Datasets, leaderboard, profiles ------------------------------------------------------------------
1172
 
1173
  const codeBox = (text) => html`<div class="code-box"><pre class="block">${text}</pre><button class="btn btn-sm" data-action="copy" data-copy="${text}">${icon("copy")} copy</button></div>`;
 
1138
  }
1139
 
1140
  // --- Sponsoring ----------------------------------------------------------------------------------------
1141
+
1142
+ const sponsorHead = () => html`<header class="page-head"><h1 class="title">Sponsor a dataset</h1><p class="sub">Ask the community for the dataset you need. Contributors build it, validators check it, and it's published openly.</p></header>`;
1143
+
1144
+ function howSponsoring(min) {
1145
+ return html`<h2 class="section">How it works</h2>
1146
+ <div class="card pad"><ol class="how">
1147
+ <li><span><b>You describe the dataset:</b> its type, how many items, the difficulty and the licence.</span></li>
1148
+ <li><span><b>You add up to ${min} example items</b>, so contributors can see what a good one looks like.</span></li>
1149
+ <li><span><b>DataBounty's team reviews the request</b> and its examples, then opens a pool for it.</span></li>
1150
+ <li><span><b>Contributors submit items.</b> Each one is checked automatically, then reviewed by validators.</span></li>
1151
+ <li><span><b>You can review items in your pool</b>, and dispute an accepted one while its dispute window is open.</span></li>
1152
+ <li><span><b>The finished dataset is published</b> on the Hugging Face Hub, crediting every contributor.</span></li>
1153
+ </ol></div>`;
1154
+ }
1155
 
1156
  async function pageSponsor() {
1157
  const live = ticket();
1158
+ let opts = null;
1159
+ try { opts = await api("/api/requesting"); } catch { opts = null; }
1160
+ if (!live()) return;
1161
+ // Until DataBounty takes requests from other apps, they're made on databounty.io.
1162
+ if (!opts || !opts.open) { await pageSponsorLink(live); return; }
1163
+ const page = $("#page");
1164
+ const me = state.me;
1165
+ if (!me) {
1166
+ mount(page, html`${sponsorHead()}${signInCard("Sign in to request a dataset", "Describe the dataset you need. DataBounty's team reviews your request, and the community builds it.")}${howSponsoring(opts.sampleMin)}`);
1167
+ return;
1168
+ }
1169
+ if (me.step !== "ready") { mount(page, html`${sponsorHead()}${setupCard(me)}`); wireSetup(); return; }
1170
+ if (!me.canSponsor) {
1171
+ mount(page, html`${sponsorHead()}<div class="card setup-card"><h2>Allow sponsoring</h2><p>You signed in before the Space could request datasets for you. Sign out, sign in again, and keep <b>sponsor</b> ticked.</p><button class="btn" data-action="sign-out">sign out</button></div>`);
1172
+ return;
1173
+ }
1174
+ mount(page, html`${sponsorHead()}
1175
+ <h2 class="section">Your requests</h2>
1176
+ <p class="section-sub">An admin reviews each request with its example items. Its pool can open once one example is approved.</p>
1177
+ <div id="my-requests"><div class="skeleton"></div></div>
1178
+ <h2 class="section">Request a dataset</h2>
1179
+ <p class="section-sub">Pick the kind of items you need, then say how many and how hard.</p>
1180
+ ${requestForm(opts)}
1181
+ ${howSponsoring(opts.sampleMin)}`);
1182
+ wireRequestForm(opts, live);
1183
+ renderMyRequests(opts, live);
1184
+ }
1185
+
1186
+ async function pageSponsorLink(live) {
1187
  const data = await overview();
1188
  if (!live()) return;
1189
  const app = safeUrl(data.appUrl || "https://console.databounty.io");
1190
  mount($("#page"), html`
1191
+ ${sponsorHead()}
1192
  <div class="card signin-card">
1193
  <img src="/static/logo.svg" alt="">
1194
  <div><h2>Request a dataset</h2><p>Describe what each item needs, how many you want and how hard they should be. Requests are made on databounty.io.</p></div>
 
1210
  </div>`);
1211
  }
1212
 
1213
+ const TYPE_HINT = "What kind of items contributors make. Each type comes with its own automated checks.";
1214
+
1215
+ function requestForm(opts) {
1216
+ const groups = {};
1217
+ opts.types.forEach((t) => { (groups[t.category] = groups[t.category] || []).push(t); });
1218
+ const d = opts.defaults;
1219
+ const label = (name, mark) => html`<div class="field-label"><span class="micro">${name}</span>${mark === "req" ? html`<span class="req">*</span>` : mark === "opt" ? html`<span class="opt">optional</span>` : ""}</div>`;
1220
+ return html`<form class="card form" id="request-form" novalidate>
1221
+ <div class="field">${label("dataset type", "req")}
1222
+ <select class="select" id="rq-type"><option value="">Pick a dataset type…</option>${Object.entries(groups).map(([category, types]) => html`<optgroup label="${cap(category)}">${types.map((t) => html`<option value="${t.id}">${t.name}</option>`)}</optgroup>`)}</select>
1223
+ <div class="hint" id="rq-type-hint">${TYPE_HINT}</div></div>
1224
+ <div class="field">${label("title", "req")}<input class="input" id="rq-title" maxlength="120" placeholder="e.g. Python CLI parsers with tricky flags"></div>
1225
+ <div class="field">${label("description", "req")}<textarea class="textarea" id="rq-description" maxlength="2000" placeholder="What the dataset is for, and what a good item looks like."></textarea></div>
1226
+ <div class="two">
1227
+ <div class="field" id="rq-language-field"></div>
1228
+ <div class="field">${label("framework", "opt")}<input class="input" id="rq-framework" maxlength="60" placeholder="e.g. Django"></div>
1229
+ </div>
1230
+ <div class="two">
1231
+ <div class="field">${label("items wanted", "req")}<input class="input" id="rq-items" type="number" min="10" max="100000" step="10" value="${d.targetItems}"></div>
1232
+ <div class="field">${label("difficulty", "req")}<select class="select" id="rq-mix" disabled><option value="">Pick a type first</option></select></div>
1233
+ </div>
1234
+ <div class="two">
1235
+ <div class="field">${label("hand-checked", "")}<select class="select" id="rq-audit" disabled><option value="">Pick a type first</option></select><div class="hint">The share of accepted items a validator also checks by hand.</div></div>
1236
+ <div class="field">${label("licence", "req")}<select class="select" id="rq-licence">${opts.licences.map((l) => html`<option value="${l.value}" ${l.value === d.proposedLicense ? raw("selected") : ""}>${l.label}</option>`)}</select><div class="hint" id="rq-licence-hint"></div></div>
1237
+ </div>
1238
+ <div class="form-foot"><span class="mono small muted" id="rq-missing"></span><button class="btn btn-submit" id="rq-submit" type="submit" disabled>${icon("send")} Send request</button></div>
1239
+ <div id="rq-result"></div>
1240
+ </form>`;
1241
+ }
1242
+
1243
+ function languageField(t) {
1244
+ const label = (mark) => html`<div class="field-label"><span class="micro">language</span>${mark}</div>`;
1245
+ if (!t) return html`${label("")}<input class="input" id="rq-language" disabled placeholder="Pick a type first">`;
1246
+ if (t.languageMode === "none") return html`${label("")}<input class="input" id="rq-language" disabled value="None needed"><div class="hint">Items of this type have no code to run.</div>`;
1247
+ if (t.languageMode === "fixed") return html`${label("")}<input class="input" id="rq-language" disabled value="${t.languages[0] || ""}"><div class="hint">This type checks items in ${t.languages[0] || "one language"} only.</div>`;
1248
+ if (t.languageMode === "choice") return html`${label(html`<span class="req">*</span>`)}<select class="select" id="rq-language"><option value="">Pick one…</option>${t.languages.map((l) => html`<option value="${l}">${l}</option>`)}</select><div class="hint">The languages this type can run and check.</div>`;
1249
+ return html`${label(html`<span class="opt">optional</span>`)}<input class="input" id="rq-language" maxlength="60" placeholder="e.g. Python">`;
1250
+ }
1251
+
1252
+ const newRequestKey = () => (window.crypto && crypto.randomUUID ? crypto.randomUUID() : `rq-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`);
1253
+
1254
+ function wireRequestForm(opts, live) {
1255
+ const form = $("#request-form");
1256
+ const button = $("#rq-submit");
1257
+ const out = $("#rq-result");
1258
+ const types = Object.fromEntries(opts.types.map((t) => [t.id, t]));
1259
+ // One key per request: sending the same form twice (a double click, a retry) files it once.
1260
+ let key = newRequestKey();
1261
+ let busy = false;
1262
+ const current = () => types[$("#rq-type").value];
1263
+ const pickType = () => {
1264
+ const t = current();
1265
+ $("#rq-type-hint").textContent = t ? t.description || TYPE_HINT : TYPE_HINT;
1266
+ mount($("#rq-language-field"), languageField(t));
1267
+ const mix = $("#rq-mix");
1268
+ mount(mix, t ? t.mixes.map((m) => html`<option value="${m.value}" ${m.value === opts.defaults.difficultyMix ? raw("selected") : ""}>${m.label}</option>`) : html`<option value="">Pick a type first</option>`);
1269
+ mix.disabled = !t;
1270
+ const audit = $("#rq-audit");
1271
+ const preferred = t && t.auditOptions.includes(opts.defaults.auditCoveragePct) ? opts.defaults.auditCoveragePct : t ? t.auditOptions[0] : null;
1272
+ mount(audit, t ? t.auditOptions.map((n) => html`<option value="${n}" ${n === preferred ? raw("selected") : ""}>${n}%</option>`) : html`<option value="">Pick a type first</option>`);
1273
+ audit.disabled = !t;
1274
+ };
1275
+ const licenceHint = () => {
1276
+ const l = opts.licences.find((x) => x.value === $("#rq-licence").value);
1277
+ $("#rq-licence-hint").textContent = l ? l.hint : "";
1278
+ };
1279
+ const missing = () => {
1280
+ const t = current();
1281
+ if (!t) return "Choose a dataset type.";
1282
+ if ($("#rq-title").value.trim().length < 5) return "Give it a title of 5 characters or more.";
1283
+ if ($("#rq-description").value.trim().length < 20) return "Describe the dataset in 20 characters or more.";
1284
+ if (t.languageMode === "choice" && !$("#rq-language").value) return "Choose the language.";
1285
+ const n = Number($("#rq-items").value);
1286
+ if (!Number.isInteger(n) || n < 10 || n > 100000) return "Ask for 10 to 100,000 items.";
1287
+ return "";
1288
+ };
1289
+ const update = () => {
1290
+ const m = missing();
1291
+ $("#rq-missing").textContent = m || "ready to send";
1292
+ button.disabled = busy || Boolean(m);
1293
+ };
1294
+ $("#rq-type").addEventListener("change", pickType);
1295
+ $("#rq-licence").addEventListener("change", licenceHint);
1296
+ form.addEventListener("input", update);
1297
+ form.addEventListener("change", update);
1298
+ form.addEventListener("submit", async (e) => {
1299
+ e.preventDefault();
1300
+ if (busy || missing()) return;
1301
+ const body = {
1302
+ datasetTypeId: current().id,
1303
+ title: $("#rq-title").value,
1304
+ description: $("#rq-description").value,
1305
+ language: $("#rq-language").value,
1306
+ framework: $("#rq-framework").value,
1307
+ targetItems: Number($("#rq-items").value),
1308
+ difficultyMix: $("#rq-mix").value,
1309
+ auditCoveragePct: Number($("#rq-audit").value),
1310
+ proposedLicense: $("#rq-licence").value,
1311
+ idempotencyKey: key,
1312
+ };
1313
+ busy = true;
1314
+ button.disabled = true;
1315
+ mount(button, html`${icon("refresh")} Sending…`);
1316
+ try {
1317
+ await api("/api/requests", { method: "POST", body });
1318
+ mount(out, notice("ok", html`<b>Request sent ✓</b> Now add example items to it under Your requests, so the team can see what a good item looks like.`));
1319
+ form.reset();
1320
+ key = newRequestKey();
1321
+ pickType();
1322
+ licenceHint();
1323
+ await renderMyRequests(opts, live);
1324
+ } catch (err) {
1325
+ mount(out, notice("bad", html`<b>Not sent.</b> ${err.message}`));
1326
+ } finally {
1327
+ busy = false;
1328
+ mount(button, html`${icon("send")} Send request`);
1329
+ update();
1330
+ out.scrollIntoView({ behavior: "smooth", block: "nearest" });
1331
+ }
1332
+ });
1333
+ pickType();
1334
+ licenceHint();
1335
+ update();
1336
+ }
1337
+
1338
+ async function renderMyRequests(opts, live) {
1339
+ let data;
1340
+ try { data = await api("/api/requests/mine"); } catch (e) {
1341
+ if (live() && $("#my-requests")) mount($("#my-requests"), notice("warn", e.message));
1342
+ return;
1343
+ }
1344
+ const box = $("#my-requests");
1345
+ if (!live() || !box) return;
1346
+ if (!data.requests.length) { mount(box, html`<div class="card empty">No requests yet. Start one below.</div>`); return; }
1347
+ mount(box, html`<div class="rows">${data.requests.map(requestCard)}</div>${data.more ? html`<p class="hint" style="margin:10px 2px 0">Showing your newest 50.</p>` : ""}`);
1348
+ $$("[data-sample]", box).forEach((input) => input.addEventListener("change", () => addSample(input, opts, live)));
1349
+ }
1350
+
1351
+ function requestCard(r) {
1352
+ const s = r.samples;
1353
+ const dots = Array.from({ length: s.wanted }, (_, i) => html`<span class="slot ${i < s.approved ? "slot-ok" : i < s.approved + s.pending ? "slot-wait" : ""}"></span>`);
1354
+ const facts = [r.type, r.targetItems ? `${fmt(r.targetItems)} items` : "", r.difficulty, r.language, r.date].filter(Boolean).join(" · ");
1355
+ return html`<div class="card request-card" data-request="${r.id}">
1356
+ <div class="head"><span class="pool-title">${r.title}</span><span class="pill pill-${r.statusTone}">${r.statusLabel}</span></div>
1357
+ <div class="mono small muted">${facts}</div>
1358
+ ${r.adminNote ? html`<div class="admin-note"><div class="micro">from the reviewer</div><p>${r.adminNote}</p></div>` : ""}
1359
+ <div class="sample-line"><span class="slots" aria-hidden="true">${dots}</span><span class="small">${r.sampleText}</span></div>
1360
+ ${r.canAddSample || r.poolId ? html`<div class="row wrap">
1361
+ ${r.canAddSample ? html`<label class="btn btn-sm btn-primary">${icon("upload")} add an example item<input type="file" accept=".json,.jsonl,.ndjson" data-sample="${r.id}" hidden></label>
1362
+ <a class="btn btn-sm" href="/api/requesting/${encodeURIComponent(r.typeId)}/example.json" download>${icon("download")} example template</a>` : ""}
1363
+ ${r.poolId ? html`<a class="btn btn-sm" href="#/pool/${encodeURIComponent(r.poolId)}">open your pool ${icon("arrow")}</a>` : ""}
1364
+ </div>` : ""}
1365
+ </div>`;
1366
+ }
1367
+
1368
+ async function addSample(input, opts, live) {
1369
+ const file = input.files && input.files[0];
1370
+ if (!file) return;
1371
+ const label = input.closest("label");
1372
+ if (label) label.classList.add("busy");
1373
+ const form = new FormData();
1374
+ form.append("file", file);
1375
+ try {
1376
+ await api(`/api/requests/${encodeURIComponent(input.dataset.sample)}/samples`, { method: "POST", form });
1377
+ toast("Example added. DataBounty checks the file, then an admin reviews it.");
1378
+ await renderMyRequests(opts, live);
1379
+ } catch (e) {
1380
+ toast(e.message);
1381
+ if (label) label.classList.remove("busy");
1382
+ input.value = "";
1383
+ }
1384
+ }
1385
+
1386
  // --- Datasets, leaderboard, profiles ------------------------------------------------------------------
1387
 
1388
  const codeBox = (text) => html`<div class="code-box"><pre class="block">${text}</pre><button class="btn btn-sm" data-action="copy" data-copy="${text}">${icon("copy")} copy</button></div>`;
web/index.html CHANGED
@@ -10,7 +10,7 @@
10
  <link rel="preconnect" href="https://fonts.googleapis.com">
11
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
12
  <link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
13
- <link rel="stylesheet" href="/static/app.css?v=7">
14
  </head>
15
  <body>
16
  <div class="shell">
@@ -19,6 +19,6 @@
19
  </div>
20
  <div class="toast" id="toast" role="status" aria-live="polite" hidden></div>
21
  <noscript><p style="padding:24px;font-family:sans-serif">This page needs JavaScript. You can also contribute at <a href="https://databounty.io">databounty.io</a>.</p></noscript>
22
- <script src="/static/app.js?v=7" defer></script>
23
  </body>
24
  </html>
 
10
  <link rel="preconnect" href="https://fonts.googleapis.com">
11
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
12
  <link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
13
+ <link rel="stylesheet" href="/static/app.css?v=8">
14
  </head>
15
  <body>
16
  <div class="shell">
 
19
  </div>
20
  <div class="toast" id="toast" role="status" aria-live="polite" hidden></div>
21
  <noscript><p style="padding:24px;font-family:sans-serif">This page needs JavaScript. You can also contribute at <a href="https://databounty.io">databounty.io</a>.</p></noscript>
22
+ <script src="/static/app.js?v=8" defer></script>
23
  </body>
24
  </html>
webapi.py CHANGED
@@ -1,536 +1,619 @@
1
- # SPDX-License-Identifier: Apache-2.0
2
- """The JSON API behind the Space's web page (web/app.js): contributing, reviewing, and public reads.
3
-
4
- Public reads come straight from databounty.py. Anything done as a person goes through account.py
5
- with the session handle the page sends as `Authorization: Bearer <handle>`. The page keeps that
6
- handle in its own storage (account.py explains why not a cookie), and a handle only ever reaches
7
- the page through claim_sign_in, which only the page that started the sign-in can complete.
8
- """
9
-
10
- from __future__ import annotations
11
-
12
- import json
13
- import re
14
- import tempfile
15
- from pathlib import Path
16
- from typing import Any
17
-
18
- from fastapi import APIRouter, Request
19
- from fastapi.responses import JSONResponse, Response
20
-
21
- import account
22
- import contribute
23
- import databounty as db
24
- import items as checks
25
- import sponsor
26
- import validate
27
-
28
- NO_STORE = {"Cache-Control": "no-store"}
29
- MAX_FILE_BYTES = 100 * 1024 * 1024
30
-
31
- router = APIRouter(prefix="/api")
32
-
33
-
34
- class ApiError(Exception):
35
- """An answer for the page: an HTTP status and a message written for people."""
36
-
37
- def __init__(self, status: int, message: str, *, needs: str = ""):
38
- super().__init__(message)
39
- self.status = status
40
- self.message = message
41
- self.needs = needs # what the page should offer next, e.g. "claim"
42
-
43
-
44
- def api_error(_request: Request, exc: ApiError) -> JSONResponse:
45
- body = {"error": exc.message, **({"needs": exc.needs} if exc.needs else {})}
46
- return JSONResponse(body, status_code=exc.status, headers=NO_STORE)
47
-
48
-
49
- def _ok(data: Any) -> JSONResponse:
50
- return JSONResponse(data, headers=NO_STORE)
51
-
52
-
53
- def _public(load):
54
- try:
55
- return load()
56
- except db.UpstreamError as exc:
57
- raise ApiError(502, str(exc)) from exc
58
-
59
-
60
- def _bearer(request: Request) -> str:
61
- header = request.headers.get("authorization", "")
62
- return header[7:].strip() if header.lower().startswith("bearer ") else ""
63
-
64
-
65
- def _session(request: Request) -> tuple[str, account.Session]:
66
- session_id = _bearer(request)
67
- session = account.session_for(session_id) if session_id else None
68
- if session is None:
69
- raise ApiError(401, "Sign in to continue.")
70
- return session_id, session
71
-
72
-
73
- def _call(session: account.Session, name: str, arguments: dict[str, Any] | None = None) -> Any:
74
- try:
75
- return account.call_tool(session, name, arguments)
76
- except account.NotSignedIn as exc:
77
- raise ApiError(401, "Your sign-in ended. Sign in again to continue.") from exc
78
- except account.ToolError as exc:
79
- raise ApiError(400, str(exc)) from exc
80
-
81
-
82
- async def _json(request: Request) -> dict[str, Any]:
83
- try:
84
- body = await request.json()
85
- except ValueError as exc:
86
- raise ApiError(400, "Send a JSON body.") from exc
87
- return body if isinstance(body, dict) else {}
88
-
89
-
90
- # --- Public ---------------------------------------------------------------------------------------
91
-
92
-
93
- @router.get("/overview")
94
- def overview():
95
- return _ok({"stats": _public(db.stats), "tiers": _public(db.tiers), "hfOrg": db.HF_ORG, "siteUrl": db.SITE_URL, "appUrl": db.APP_URL})
96
-
97
-
98
- @router.get("/pools")
99
- def pools(q: str = "", domain: str = "", difficulty: str = ""):
100
- return _ok({"pools": _public(lambda: db.open_pools(q, domain.lower(), difficulty.lower()))})
101
-
102
-
103
- @router.get("/datasets")
104
- def datasets():
105
- return _ok({"datasets": _public(db.published_datasets)})
106
-
107
-
108
- @router.get("/leaderboard")
109
- def leaderboard(tier: str = "", q: str = ""):
110
- return _ok({"rows": _public(lambda: db.leaderboard(limit=100, tier=tier.lower(), query=q))})
111
-
112
-
113
- @router.get("/profiles/{handle}")
114
- def profile(handle: str):
115
- try:
116
- person = _public(lambda: db.contributor(handle))
117
- except ValueError as exc:
118
- raise ApiError(400, str(exc)) from exc
119
- if person is None:
120
- raise ApiError(404, "There's no public profile with that handle. Members can keep their profile private.")
121
- return _ok(person)
122
-
123
-
124
- @router.get("/tools")
125
- def tools():
126
- return _ok({"tools": _public(db.platform_mcp_tools), "mcpUrl": db.MCP_URL})
127
-
128
-
129
- # --- Session and account ------------------------------------------------------------------------------
130
-
131
-
132
- @router.post("/session/claim")
133
- async def claim_session(request: Request):
134
- nonce = str((await _json(request)).get("nonce") or "")
135
- session_id = account.claim_sign_in(nonce)
136
- if not session_id:
137
- raise ApiError(404, "That sign-in isn't ready, or it was already used.")
138
- return _ok({"session": session_id})
139
-
140
-
141
- @router.post("/session/sign-out")
142
- def sign_out(request: Request):
143
- account.sign_out(_bearer(request) or None)
144
- return _ok({"ok": True})
145
-
146
-
147
- @router.get("/me")
148
- def me(request: Request):
149
- _, session = _session(request)
150
- who = _call(session, "whoami")
151
- session.me = who
152
- scopes = set(session.scope.split())
153
- step = (
154
- "permission" if "contribute" not in scopes
155
- else "verify_email" if not who.get("emailVerified")
156
- else "choose_handle" if not who.get("handle")
157
- else "finish_setup" if not who.get("onboarded")
158
- else "ready"
159
- )
160
- suggestion = ""
161
- if step == "choose_handle" and "account" in scopes:
162
- try:
163
- suggested = account.call_tool(session, "suggest_handles", {"baseName": who.get("displayName") or "", "count": 3})
164
- suggestion = suggested[0] if isinstance(suggested, list) and suggested else ""
165
- except (account.ToolError, account.NotSignedIn):
166
- suggestion = ""
167
- return _ok({
168
- "displayName": who.get("displayName") or "",
169
- "handle": who.get("handle") or "",
170
- "email": who.get("email") or "",
171
- "karma": who.get("karmaTotal") if isinstance(who.get("karmaTotal"), (int, float)) else 0,
172
- "step": step,
173
- "suggestedHandle": suggestion,
174
- "profileUrl": f"{db.SITE_URL}/{who.get('handle')}/" if who.get("handle") else "",
175
- # Sign-ins from before the Space asked for `validate` or `sponsor` can't use those screens.
176
- "canReview": "validate" in scopes,
177
- "canSponsor": "sponsor" in scopes,
178
- "reviewer": validate.rank_view(who),
179
- })
180
-
181
-
182
- @router.post("/me/handle-check")
183
- async def handle_check(request: Request):
184
- _, session = _session(request)
185
- handle = str((await _json(request)).get("handle") or "").strip().lower().lstrip("@")
186
- if not handle:
187
- raise ApiError(400, "Type a username first.")
188
- result = _call(session, "get_handle_availability", {"handle": handle})
189
- return _ok({"handle": handle, "available": bool(result.get("available")), "reason": result.get("reason") or ""})
190
-
191
-
192
- @router.post("/me/finish-setup")
193
- async def finish_setup(request: Request):
194
- _, session = _session(request)
195
- handle = str((await _json(request)).get("handle") or "").strip().lower().lstrip("@")
196
- who = session.me or _call(session, "whoami")
197
- if not who.get("handle"):
198
- if not handle:
199
- raise ApiError(400, "Type the username you want first.")
200
- result = _call(session, "get_handle_availability", {"handle": handle})
201
- if not result.get("available"):
202
- raise ApiError(409, f"@{handle} isn't available: {result.get('reason') or 'it is taken'}.")
203
- _call(session, "claim_handle", {"handle": handle})
204
- _call(session, "complete_onboarding", {"persona": "contributor"})
205
- session.me = None
206
- return _ok({"ok": True})
207
-
208
-
209
- @router.post("/me/resend-verification")
210
- def resend_verification(request: Request):
211
- _, session = _session(request)
212
- result = _call(session, "resend_email_verification") or {}
213
- return _ok({"alreadyVerified": result.get("status") == "already_verified"})
214
-
215
-
216
- # --- Pools ---------------------------------------------------------------------------------------------
217
-
218
-
219
- def _contract(session: account.Session, pool_id: str) -> dict[str, Any]:
220
- return contribute.contract_view(_call(session, "get_pool_contract", {"bountyId": pool_id}), pool_id)
221
-
222
-
223
- @router.get("/pools/{pool_id}/contract")
224
- def contract(pool_id: str, request: Request):
225
- session = account.session_for(_bearer(request)) if _bearer(request) else None
226
- if session is not None:
227
- return _ok(_contract(session, pool_id))
228
- try:
229
- details = _public(lambda: db.pool_details(pool_id))
230
- except ValueError as exc:
231
- raise ApiError(400, str(exc)) from exc
232
- if details is None:
233
- raise ApiError(404, "That pool is no longer listed.")
234
- return _ok(contribute.public_contract_view(details))
235
-
236
-
237
- @router.get("/pools/{pool_id}/template.csv")
238
- def template(pool_id: str):
239
- try:
240
- details = _public(lambda: db.pool_details(pool_id))
241
- except ValueError as exc:
242
- raise ApiError(400, str(exc)) from exc
243
- if details is None:
244
- raise ApiError(404, "That pool is no longer listed.")
245
- text = contribute.template_csv(details.get("fields") or [])
246
- if not text:
247
- raise ApiError(404, "This pool takes files, so add its items one at a time.")
248
- slug = re.sub(r"[^a-z0-9]+", "-", str(details.get("title") or "pool").lower()).strip("-")[:48] or "pool"
249
- return Response(
250
- text,
251
- media_type="text/csv; charset=utf-8",
252
- headers={"Content-Disposition": f'attachment; filename="{slug}-template.csv"', **NO_STORE},
253
- )
254
-
255
-
256
- async def _save(upload: Any) -> tuple[str, str, str]:
257
- """An uploaded file, to a temporary path: (path, filename, content type)."""
258
- data = await upload.read()
259
- if len(data) > MAX_FILE_BYTES:
260
- raise ApiError(413, f"{upload.filename} is larger than {MAX_FILE_BYTES // (1024 * 1024)} MB.")
261
- suffix = Path(upload.filename or "upload").suffix[:16]
262
- with tempfile.NamedTemporaryFile(delete=False, suffix=suffix, prefix="databounty-space-") as handle:
263
- handle.write(data)
264
- return handle.name, Path(upload.filename or "upload").name, upload.content_type or "application/octet-stream"
265
-
266
-
267
- @router.post("/pools/{pool_id}/items")
268
- async def submit_item(pool_id: str, request: Request):
269
- _, session = _session(request)
270
- # Closing the form deletes the uploads' temporary files.
271
- async with request.form() as form:
272
- return await _submit_item(session, pool_id, form)
273
-
274
-
275
- async def _submit_item(session: account.Session, pool_id: str, form: Any) -> JSONResponse:
276
- method = str(form.get("method") or "")
277
- if method not in contribute.GENERATION_METHODS:
278
- raise ApiError(400, "Choose how you made this item.")
279
- try:
280
- values = json.loads(str(form.get("values") or "{}"))
281
- except ValueError as exc:
282
- raise ApiError(400, "The form's values didn't arrive in one piece. Try again.") from exc
283
- files: dict[str, list[Any]] = {}
284
- for name, value in form.multi_items():
285
- if name.startswith("file:") and getattr(value, "filename", None):
286
- files.setdefault(name[5:], []).append(value)
287
-
288
- contract_data = _contract(session, pool_id)
289
- if not contract_data["open"]:
290
- raise ApiError(409, "This pool isn't taking items right now.")
291
- payload, problems = contribute.item_payload(contract_data["fields"], values if isinstance(values, dict) else {}, {k: len(v) for k, v in files.items()})
292
- if problems:
293
- raise ApiError(400, " ".join(problems))
294
-
295
- saved: list[str] = []
296
- try:
297
- for spec in contract_data["fields"]:
298
- uploads = files.get(spec["key"]) or []
299
- if spec["role"] != "file" or not uploads:
300
- continue
301
- ids = []
302
- for upload in uploads:
303
- path, filename, content_type = await _save(upload)
304
- saved.append(path)
305
- try:
306
- ids.append(account.upload_file(session, pool_id, path, filename, content_type))
307
- except account.NotSignedIn as exc:
308
- raise ApiError(401, "Your sign-in ended. Sign in again to continue.") from exc
309
- except account.ToolError as exc:
310
- raise ApiError(400, str(exc)) from exc
311
- payload[spec["key"]] = ids if spec["maxCount"] > 1 else ids[0]
312
- finally:
313
- for path in saved:
314
- Path(path).unlink(missing_ok=True)
315
-
316
- title = " ".join(str(form.get("title") or "").split())[:120] or contribute.title_for(payload)
317
- if len(title) < 3:
318
- title = f"{title} item".strip()
319
- result = _call(
320
- session,
321
- "submit_pool_items",
322
- {"bountyId": pool_id, "items": [{"title": title, "payloadJson": payload, "generationMethod": method}]},
323
- )
324
- subs = result.get("submissions") or []
325
- duplicate = bool(subs) and all(s.get("status") == "rejected" and s.get("duplicateOfSubmissionId") for s in subs)
326
- return _ok({"duplicate": duplicate, "submissions": [{"id": s.get("id"), "status": s.get("status")} for s in subs], "karma": contract_data["karma"]})
327
-
328
-
329
- @router.post("/pools/{pool_id}/bulk/preview")
330
- async def bulk_preview(pool_id: str, request: Request):
331
- _, session = _session(request)
332
- async with request.form() as form:
333
- upload = form.get("file")
334
- if not getattr(upload, "filename", None):
335
- raise ApiError(400, "Choose a file first.")
336
- data = await upload.read()
337
- fields = _contract(session, pool_id)["fields"]
338
- try:
339
- rows = checks.parse_bulk(data, upload.filename)
340
- except ValueError as exc:
341
- raise ApiError(400, str(exc)) from exc
342
- if len(rows) > contribute.MAX_BULK_ROWS:
343
- raise ApiError(400, f"That file has {len(rows):,} rows. Upload up to {contribute.MAX_BULK_ROWS} at a time.")
344
- for row in rows:
345
- if row.payload is not None:
346
- row.payload = checks.coerce_for_contract(fields, row.payload)
347
- checks.check_rows(rows, fields)
348
- return _ok({
349
- "rows": [
350
- {
351
- "number": row.number,
352
- "ok": row.ok,
353
- "problem": "" if row.ok else (row.error or "; ".join(row.problems)),
354
- "title": contribute.title_for(row.payload, row.number),
355
- "payload": row.payload,
356
- }
357
- for row in rows
358
- ],
359
- "hasFileFields": any(spec["role"] == "file" for spec in fields),
360
- })
361
-
362
-
363
- @router.post("/pools/{pool_id}/bulk/send")
364
- async def bulk_send(pool_id: str, request: Request):
365
- _, session = _session(request)
366
- body = await _json(request)
367
- method = str(body.get("method") or "")
368
- if method not in contribute.GENERATION_METHODS:
369
- raise ApiError(400, "Choose how you made these items.")
370
- contract_data = _contract(session, pool_id)
371
- if not contract_data["open"]:
372
- raise ApiError(409, "This pool isn't taking items right now.")
373
- # Every row is checked again here: the page's copy is only a preview.
374
- items, skipped = [], 0
375
- for row in body.get("rows") or []:
376
- payload = row.get("payload") if isinstance(row, dict) else None
377
- if not isinstance(payload, dict) or checks.item_problems(contract_data["fields"], payload):
378
- skipped += 1
379
- continue
380
- number = int(row.get("number") or 0)
381
- title = " ".join(str(row.get("title") or "").split())[:120] or contribute.title_for(payload, number)
382
- items.append({"title": title, "payloadJson": payload, "generationMethod": method})
383
- if not items:
384
- raise ApiError(400, "None of those rows are ready to send.")
385
- sent = duplicates = 0
386
- problem = ""
387
- size = max(1, contract_data["maxItems"])
388
- for start in range(0, len(items), size):
389
- try:
390
- result = account.call_tool(session, "submit_pool_items", {"bountyId": pool_id, "items": items[start : start + size]})
391
- except account.NotSignedIn:
392
- problem = "Your sign-in ended, so the rest weren't sent. Sign in again to continue."
393
- break
394
- except account.ToolError as exc:
395
- problem = f"Items {start + 1} onwards weren't sent: {exc}"
396
- break
397
- created = result.get("submissions") or []
398
- sent += len(created)
399
- duplicates += sum(1 for s in created if s.get("status") == "rejected" and s.get("duplicateOfSubmissionId"))
400
- if result.get("stoppedAtCapacity"):
401
- problem = "The pool filled up, so the rest weren't sent."
402
- break
403
- return _ok({"sent": sent, "duplicates": duplicates, "skipped": skipped, "problem": problem, "karma": contract_data["karma"]})
404
-
405
-
406
- # --- Submissions ------------------------------------------------------------------------------------------
407
-
408
-
409
- @router.get("/submissions")
410
- def submissions(request: Request):
411
- _, session = _session(request)
412
- data = _call(session, "list_my_submissions", {"limit": 50}) or {}
413
- rows = [contribute.submission_row(item) for item in data.get("items") or []]
414
- return _ok({"items": rows, "total": data.get("total") if isinstance(data.get("total"), int) else len(rows)})
415
-
416
-
417
- @router.get("/submissions/{submission_id}")
418
- def submission(submission_id: str, request: Request):
419
- _, session = _session(request)
420
- return _ok(contribute.submission_view(_call(session, "check_submission", {"submissionId": submission_id}), db.APP_URL))
421
-
422
-
423
- # --- Reviewing --------------------------------------------------------------------------------------
424
- # A review is an audit: a few items from one pool, held by one reviewer for 24 hours.
425
-
426
-
427
- def _reviewer(request: Request) -> account.Session:
428
- _, session = _session(request)
429
- if "validate" not in session.scope.split():
430
- raise ApiError(403, "The Space isn't allowed to review for you. Sign out, then sign in again and keep validate ticked.")
431
- return session
432
-
433
-
434
- @router.get("/audits")
435
- def audits(request: Request, q: str = ""):
436
- session = _reviewer(request)
437
- search = " ".join(q.split())[:120]
438
- data = _call(session, "list_audits", {"limit": 50, **({"search": search} if search else {})}) or {}
439
- rows = [validate.audit_row(a) for a in data.get("audits") or []]
440
- return _ok({
441
- "audits": rows,
442
- "total": data.get("total") if isinstance(data.get("total"), int) else len(rows),
443
- # Audits holding the reviewer's own work, which they can't review.
444
- "hidden": data.get("conflictExcluded") if isinstance(data.get("conflictExcluded"), int) else 0,
445
- })
446
-
447
-
448
- @router.get("/audits/mine")
449
- def my_audits(request: Request, status: str = "claimed"):
450
- session = _reviewer(request)
451
- status = status if status in ("claimed", "overdue_review", "completed") else "claimed"
452
- data = _call(session, "list_my_audits", {"status": status, "limit": 50}) or {}
453
- rows = [validate.audit_row(a) for a in data.get("audits") or []]
454
- return _ok({"audits": rows, "total": data.get("total") if isinstance(data.get("total"), int) else len(rows)})
455
-
456
-
457
- @router.post("/audits/{audit_id}/claim")
458
- def claim_audit(audit_id: str, request: Request):
459
- session = _reviewer(request)
460
- window = (_call(session, "claim_audit", {"windowId": audit_id}) or {}).get("window") or {}
461
- return _ok({"id": window.get("id") or audit_id, "deadline": window.get("claimExpiresAt")})
462
-
463
-
464
- @router.get("/audits/{audit_id}")
465
- def audit(audit_id: str, request: Request):
466
- session = _reviewer(request)
467
- try:
468
- detail = account.call_tool(session, "get_audit", {"windowId": audit_id})
469
- except account.NotSignedIn as exc:
470
- raise ApiError(401, "Your sign-in ended. Sign in again to continue.") from exc
471
- except account.ToolError as exc:
472
- # The items stay hidden until the reviewer holds the audit (DataBounty's rule).
473
- if "claim_audit" in str(exc) or "Claim this audit" in str(exc):
474
- raise ApiError(409, "Claim this audit to see its items. It's yours for 24 hours.", needs="claim") from exc
475
- raise ApiError(400, str(exc)) from exc
476
- return _ok(validate.audit_view(detail or {}, db.APP_URL))
477
-
478
-
479
- @router.post("/audits/{audit_id}/decisions")
480
- async def decide(audit_id: str, request: Request):
481
- session = _reviewer(request)
482
- decision, problem = validate.decision(await _json(request))
483
- if problem:
484
- raise ApiError(400, problem)
485
- result = _call(session, "submit_decisions", {"windowId": audit_id, "decisions": [decision]}) or {}
486
- return _ok({"ok": True, "decided": result.get("decisionsCount", 1)})
487
-
488
-
489
- # --- Sponsoring ----------------------------------------------------------------------------------------
490
- # Only a pool's requester can see its items (DataBounty's rule), so the page asks on every pool and
491
- # shows the panel only when DataBounty says yes. Requesting a dataset stays on databounty.io.
492
-
493
-
494
- def _sponsor(request: Request) -> account.Session:
495
- _, session = _session(request)
496
- if "sponsor" not in session.scope.split():
497
- raise ApiError(403, "Sign out, then sign in again and keep sponsor ticked, to see your pools here.", needs="sponsor_scope")
498
- return session
499
-
500
-
501
- def _sponsor_call(session: account.Session, name: str, arguments: dict[str, Any]) -> Any:
502
- try:
503
- return account.call_tool(session, name, arguments)
504
- except account.NotSignedIn as exc:
505
- raise ApiError(401, "Your sign-in ended. Sign in again to continue.") from exc
506
- except account.ToolError as exc:
507
- message = str(exc)
508
- if "owner" in message.lower() or "forbidden" in message.lower():
509
- raise ApiError(403, "Only the person who requested this pool can see its items.", needs="not_sponsor") from exc
510
- raise ApiError(400, message) from exc
511
-
512
-
513
- @router.get("/pools/{pool_id}/sponsor")
514
- def sponsor_pool(pool_id: str, request: Request, status: str = "", q: str = ""):
515
- session = _sponsor(request)
516
- arguments: dict[str, Any] = {"bountyId": pool_id, "limit": 50}
517
- if status.strip():
518
- arguments["status"] = status.strip()
519
- if q.strip():
520
- arguments["search"] = " ".join(q.split())[:120]
521
- data = _sponsor_call(session, "get_sponsor_submission_evidence", arguments) or {}
522
- try:
523
- specs = (db.pool_details(pool_id) or {}).get("fields") or []
524
- except (ValueError, db.UpstreamError):
525
- specs = [] # labels are a nicety; the items still show under their keys
526
- return _ok(sponsor.pool_view(data, specs))
527
-
528
-
529
- @router.post("/sponsor/submissions/{submission_id}/dispute")
530
- async def sponsor_dispute(submission_id: str, request: Request):
531
- session = _sponsor(request)
532
- arguments, problem = sponsor.dispute(await _json(request))
533
- if problem:
534
- raise ApiError(400, problem)
535
- _sponsor_call(session, "dispute_accepted_submission", {"submissionId": submission_id, **arguments})
536
- return _ok({"ok": True})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """The JSON API behind the Space's web page (web/app.js): contributing, reviewing, and public reads.
3
+
4
+ Public reads come straight from databounty.py. Anything done as a person goes through account.py
5
+ with the session handle the page sends as `Authorization: Bearer <handle>`. The page keeps that
6
+ handle in its own storage (account.py explains why not a cookie), and a handle only ever reaches
7
+ the page through claim_sign_in, which only the page that started the sign-in can complete.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import re
14
+ import tempfile
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from fastapi import APIRouter, Request
19
+ from fastapi.responses import JSONResponse, Response
20
+
21
+ import account
22
+ import contribute
23
+ import databounty as db
24
+ import items as checks
25
+ import requesting
26
+ import sponsor
27
+ import validate
28
+
29
+ NO_STORE = {"Cache-Control": "no-store"}
30
+ MAX_FILE_BYTES = 100 * 1024 * 1024
31
+
32
+ router = APIRouter(prefix="/api")
33
+
34
+
35
+ class ApiError(Exception):
36
+ """An answer for the page: an HTTP status and a message written for people."""
37
+
38
+ def __init__(self, status: int, message: str, *, needs: str = ""):
39
+ super().__init__(message)
40
+ self.status = status
41
+ self.message = message
42
+ self.needs = needs # what the page should offer next, e.g. "claim"
43
+
44
+
45
+ def api_error(_request: Request, exc: ApiError) -> JSONResponse:
46
+ body = {"error": exc.message, **({"needs": exc.needs} if exc.needs else {})}
47
+ return JSONResponse(body, status_code=exc.status, headers=NO_STORE)
48
+
49
+
50
+ def _ok(data: Any) -> JSONResponse:
51
+ return JSONResponse(data, headers=NO_STORE)
52
+
53
+
54
+ def _public(load):
55
+ try:
56
+ return load()
57
+ except db.UpstreamError as exc:
58
+ raise ApiError(502, str(exc)) from exc
59
+
60
+
61
+ def _bearer(request: Request) -> str:
62
+ header = request.headers.get("authorization", "")
63
+ return header[7:].strip() if header.lower().startswith("bearer ") else ""
64
+
65
+
66
+ def _session(request: Request) -> tuple[str, account.Session]:
67
+ session_id = _bearer(request)
68
+ session = account.session_for(session_id) if session_id else None
69
+ if session is None:
70
+ raise ApiError(401, "Sign in to continue.")
71
+ return session_id, session
72
+
73
+
74
+ def _call(session: account.Session, name: str, arguments: dict[str, Any] | None = None) -> Any:
75
+ try:
76
+ return account.call_tool(session, name, arguments)
77
+ except account.NotSignedIn as exc:
78
+ raise ApiError(401, "Your sign-in ended. Sign in again to continue.") from exc
79
+ except account.ToolError as exc:
80
+ raise ApiError(400, str(exc)) from exc
81
+
82
+
83
+ async def _json(request: Request) -> dict[str, Any]:
84
+ try:
85
+ body = await request.json()
86
+ except ValueError as exc:
87
+ raise ApiError(400, "Send a JSON body.") from exc
88
+ return body if isinstance(body, dict) else {}
89
+
90
+
91
+ # --- Public ---------------------------------------------------------------------------------------
92
+
93
+
94
+ @router.get("/overview")
95
+ def overview():
96
+ return _ok({"stats": _public(db.stats), "tiers": _public(db.tiers), "hfOrg": db.HF_ORG, "siteUrl": db.SITE_URL, "appUrl": db.APP_URL})
97
+
98
+
99
+ @router.get("/pools")
100
+ def pools(q: str = "", domain: str = "", difficulty: str = ""):
101
+ return _ok({"pools": _public(lambda: db.open_pools(q, domain.lower(), difficulty.lower()))})
102
+
103
+
104
+ @router.get("/datasets")
105
+ def datasets():
106
+ return _ok({"datasets": _public(db.published_datasets)})
107
+
108
+
109
+ @router.get("/leaderboard")
110
+ def leaderboard(tier: str = "", q: str = ""):
111
+ return _ok({"rows": _public(lambda: db.leaderboard(limit=100, tier=tier.lower(), query=q))})
112
+
113
+
114
+ @router.get("/profiles/{handle}")
115
+ def profile(handle: str):
116
+ try:
117
+ person = _public(lambda: db.contributor(handle))
118
+ except ValueError as exc:
119
+ raise ApiError(400, str(exc)) from exc
120
+ if person is None:
121
+ raise ApiError(404, "There's no public profile with that handle. Members can keep their profile private.")
122
+ return _ok(person)
123
+
124
+
125
+ @router.get("/tools")
126
+ def tools():
127
+ return _ok({"tools": _public(db.platform_mcp_tools), "mcpUrl": db.MCP_URL})
128
+
129
+
130
+ # --- Session and account ------------------------------------------------------------------------------
131
+
132
+
133
+ @router.post("/session/claim")
134
+ async def claim_session(request: Request):
135
+ nonce = str((await _json(request)).get("nonce") or "")
136
+ session_id = account.claim_sign_in(nonce)
137
+ if not session_id:
138
+ raise ApiError(404, "That sign-in isn't ready, or it was already used.")
139
+ return _ok({"session": session_id})
140
+
141
+
142
+ @router.post("/session/sign-out")
143
+ def sign_out(request: Request):
144
+ account.sign_out(_bearer(request) or None)
145
+ return _ok({"ok": True})
146
+
147
+
148
+ @router.get("/me")
149
+ def me(request: Request):
150
+ _, session = _session(request)
151
+ who = _call(session, "whoami")
152
+ session.me = who
153
+ scopes = set(session.scope.split())
154
+ step = (
155
+ "permission" if "contribute" not in scopes
156
+ else "verify_email" if not who.get("emailVerified")
157
+ else "choose_handle" if not who.get("handle")
158
+ else "finish_setup" if not who.get("onboarded")
159
+ else "ready"
160
+ )
161
+ suggestion = ""
162
+ if step == "choose_handle" and "account" in scopes:
163
+ try:
164
+ suggested = account.call_tool(session, "suggest_handles", {"baseName": who.get("displayName") or "", "count": 3})
165
+ suggestion = suggested[0] if isinstance(suggested, list) and suggested else ""
166
+ except (account.ToolError, account.NotSignedIn):
167
+ suggestion = ""
168
+ return _ok({
169
+ "displayName": who.get("displayName") or "",
170
+ "handle": who.get("handle") or "",
171
+ "email": who.get("email") or "",
172
+ "karma": who.get("karmaTotal") if isinstance(who.get("karmaTotal"), (int, float)) else 0,
173
+ "step": step,
174
+ "suggestedHandle": suggestion,
175
+ "profileUrl": f"{db.SITE_URL}/{who.get('handle')}/" if who.get("handle") else "",
176
+ # Sign-ins from before the Space asked for `validate` or `sponsor` can't use those screens.
177
+ "canReview": "validate" in scopes,
178
+ "canSponsor": "sponsor" in scopes,
179
+ "reviewer": validate.rank_view(who),
180
+ })
181
+
182
+
183
+ @router.post("/me/handle-check")
184
+ async def handle_check(request: Request):
185
+ _, session = _session(request)
186
+ handle = str((await _json(request)).get("handle") or "").strip().lower().lstrip("@")
187
+ if not handle:
188
+ raise ApiError(400, "Type a username first.")
189
+ result = _call(session, "get_handle_availability", {"handle": handle})
190
+ return _ok({"handle": handle, "available": bool(result.get("available")), "reason": result.get("reason") or ""})
191
+
192
+
193
+ @router.post("/me/finish-setup")
194
+ async def finish_setup(request: Request):
195
+ _, session = _session(request)
196
+ handle = str((await _json(request)).get("handle") or "").strip().lower().lstrip("@")
197
+ who = session.me or _call(session, "whoami")
198
+ if not who.get("handle"):
199
+ if not handle:
200
+ raise ApiError(400, "Type the username you want first.")
201
+ result = _call(session, "get_handle_availability", {"handle": handle})
202
+ if not result.get("available"):
203
+ raise ApiError(409, f"@{handle} isn't available: {result.get('reason') or 'it is taken'}.")
204
+ _call(session, "claim_handle", {"handle": handle})
205
+ _call(session, "complete_onboarding", {"persona": "contributor"})
206
+ session.me = None
207
+ return _ok({"ok": True})
208
+
209
+
210
+ @router.post("/me/resend-verification")
211
+ def resend_verification(request: Request):
212
+ _, session = _session(request)
213
+ result = _call(session, "resend_email_verification") or {}
214
+ return _ok({"alreadyVerified": result.get("status") == "already_verified"})
215
+
216
+
217
+ # --- Pools ---------------------------------------------------------------------------------------------
218
+
219
+
220
+ def _contract(session: account.Session, pool_id: str) -> dict[str, Any]:
221
+ return contribute.contract_view(_call(session, "get_pool_contract", {"bountyId": pool_id}), pool_id)
222
+
223
+
224
+ @router.get("/pools/{pool_id}/contract")
225
+ def contract(pool_id: str, request: Request):
226
+ session = account.session_for(_bearer(request)) if _bearer(request) else None
227
+ if session is not None:
228
+ return _ok(_contract(session, pool_id))
229
+ try:
230
+ details = _public(lambda: db.pool_details(pool_id))
231
+ except ValueError as exc:
232
+ raise ApiError(400, str(exc)) from exc
233
+ if details is None:
234
+ raise ApiError(404, "That pool is no longer listed.")
235
+ return _ok(contribute.public_contract_view(details))
236
+
237
+
238
+ @router.get("/pools/{pool_id}/template.csv")
239
+ def template(pool_id: str):
240
+ try:
241
+ details = _public(lambda: db.pool_details(pool_id))
242
+ except ValueError as exc:
243
+ raise ApiError(400, str(exc)) from exc
244
+ if details is None:
245
+ raise ApiError(404, "That pool is no longer listed.")
246
+ text = contribute.template_csv(details.get("fields") or [])
247
+ if not text:
248
+ raise ApiError(404, "This pool takes files, so add its items one at a time.")
249
+ slug = re.sub(r"[^a-z0-9]+", "-", str(details.get("title") or "pool").lower()).strip("-")[:48] or "pool"
250
+ return Response(
251
+ text,
252
+ media_type="text/csv; charset=utf-8",
253
+ headers={"Content-Disposition": f'attachment; filename="{slug}-template.csv"', **NO_STORE},
254
+ )
255
+
256
+
257
+ async def _save(upload: Any) -> tuple[str, str, str]:
258
+ """An uploaded file, to a temporary path: (path, filename, content type)."""
259
+ data = await upload.read()
260
+ if len(data) > MAX_FILE_BYTES:
261
+ raise ApiError(413, f"{upload.filename} is larger than {MAX_FILE_BYTES // (1024 * 1024)} MB.")
262
+ suffix = Path(upload.filename or "upload").suffix[:16]
263
+ with tempfile.NamedTemporaryFile(delete=False, suffix=suffix, prefix="databounty-space-") as handle:
264
+ handle.write(data)
265
+ return handle.name, Path(upload.filename or "upload").name, upload.content_type or "application/octet-stream"
266
+
267
+
268
+ @router.post("/pools/{pool_id}/items")
269
+ async def submit_item(pool_id: str, request: Request):
270
+ _, session = _session(request)
271
+ # Closing the form deletes the uploads' temporary files.
272
+ async with request.form() as form:
273
+ return await _submit_item(session, pool_id, form)
274
+
275
+
276
+ async def _submit_item(session: account.Session, pool_id: str, form: Any) -> JSONResponse:
277
+ method = str(form.get("method") or "")
278
+ if method not in contribute.GENERATION_METHODS:
279
+ raise ApiError(400, "Choose how you made this item.")
280
+ try:
281
+ values = json.loads(str(form.get("values") or "{}"))
282
+ except ValueError as exc:
283
+ raise ApiError(400, "The form's values didn't arrive in one piece. Try again.") from exc
284
+ files: dict[str, list[Any]] = {}
285
+ for name, value in form.multi_items():
286
+ if name.startswith("file:") and getattr(value, "filename", None):
287
+ files.setdefault(name[5:], []).append(value)
288
+
289
+ contract_data = _contract(session, pool_id)
290
+ if not contract_data["open"]:
291
+ raise ApiError(409, "This pool isn't taking items right now.")
292
+ payload, problems = contribute.item_payload(contract_data["fields"], values if isinstance(values, dict) else {}, {k: len(v) for k, v in files.items()})
293
+ if problems:
294
+ raise ApiError(400, " ".join(problems))
295
+
296
+ saved: list[str] = []
297
+ try:
298
+ for spec in contract_data["fields"]:
299
+ uploads = files.get(spec["key"]) or []
300
+ if spec["role"] != "file" or not uploads:
301
+ continue
302
+ ids = []
303
+ for upload in uploads:
304
+ path, filename, content_type = await _save(upload)
305
+ saved.append(path)
306
+ try:
307
+ ids.append(account.upload_file(session, pool_id, path, filename, content_type))
308
+ except account.NotSignedIn as exc:
309
+ raise ApiError(401, "Your sign-in ended. Sign in again to continue.") from exc
310
+ except account.ToolError as exc:
311
+ raise ApiError(400, str(exc)) from exc
312
+ payload[spec["key"]] = ids if spec["maxCount"] > 1 else ids[0]
313
+ finally:
314
+ for path in saved:
315
+ Path(path).unlink(missing_ok=True)
316
+
317
+ title = " ".join(str(form.get("title") or "").split())[:120] or contribute.title_for(payload)
318
+ if len(title) < 3:
319
+ title = f"{title} item".strip()
320
+ result = _call(
321
+ session,
322
+ "submit_pool_items",
323
+ {"bountyId": pool_id, "items": [{"title": title, "payloadJson": payload, "generationMethod": method}]},
324
+ )
325
+ subs = result.get("submissions") or []
326
+ duplicate = bool(subs) and all(s.get("status") == "rejected" and s.get("duplicateOfSubmissionId") for s in subs)
327
+ return _ok({"duplicate": duplicate, "submissions": [{"id": s.get("id"), "status": s.get("status")} for s in subs], "karma": contract_data["karma"]})
328
+
329
+
330
+ @router.post("/pools/{pool_id}/bulk/preview")
331
+ async def bulk_preview(pool_id: str, request: Request):
332
+ _, session = _session(request)
333
+ async with request.form() as form:
334
+ upload = form.get("file")
335
+ if not getattr(upload, "filename", None):
336
+ raise ApiError(400, "Choose a file first.")
337
+ data = await upload.read()
338
+ fields = _contract(session, pool_id)["fields"]
339
+ try:
340
+ rows = checks.parse_bulk(data, upload.filename)
341
+ except ValueError as exc:
342
+ raise ApiError(400, str(exc)) from exc
343
+ if len(rows) > contribute.MAX_BULK_ROWS:
344
+ raise ApiError(400, f"That file has {len(rows):,} rows. Upload up to {contribute.MAX_BULK_ROWS} at a time.")
345
+ for row in rows:
346
+ if row.payload is not None:
347
+ row.payload = checks.coerce_for_contract(fields, row.payload)
348
+ checks.check_rows(rows, fields)
349
+ return _ok({
350
+ "rows": [
351
+ {
352
+ "number": row.number,
353
+ "ok": row.ok,
354
+ "problem": "" if row.ok else (row.error or "; ".join(row.problems)),
355
+ "title": contribute.title_for(row.payload, row.number),
356
+ "payload": row.payload,
357
+ }
358
+ for row in rows
359
+ ],
360
+ "hasFileFields": any(spec["role"] == "file" for spec in fields),
361
+ })
362
+
363
+
364
+ @router.post("/pools/{pool_id}/bulk/send")
365
+ async def bulk_send(pool_id: str, request: Request):
366
+ _, session = _session(request)
367
+ body = await _json(request)
368
+ method = str(body.get("method") or "")
369
+ if method not in contribute.GENERATION_METHODS:
370
+ raise ApiError(400, "Choose how you made these items.")
371
+ contract_data = _contract(session, pool_id)
372
+ if not contract_data["open"]:
373
+ raise ApiError(409, "This pool isn't taking items right now.")
374
+ # Every row is checked again here: the page's copy is only a preview.
375
+ items, skipped = [], 0
376
+ for row in body.get("rows") or []:
377
+ payload = row.get("payload") if isinstance(row, dict) else None
378
+ if not isinstance(payload, dict) or checks.item_problems(contract_data["fields"], payload):
379
+ skipped += 1
380
+ continue
381
+ number = int(row.get("number") or 0)
382
+ title = " ".join(str(row.get("title") or "").split())[:120] or contribute.title_for(payload, number)
383
+ items.append({"title": title, "payloadJson": payload, "generationMethod": method})
384
+ if not items:
385
+ raise ApiError(400, "None of those rows are ready to send.")
386
+ sent = duplicates = 0
387
+ problem = ""
388
+ size = max(1, contract_data["maxItems"])
389
+ for start in range(0, len(items), size):
390
+ try:
391
+ result = account.call_tool(session, "submit_pool_items", {"bountyId": pool_id, "items": items[start : start + size]})
392
+ except account.NotSignedIn:
393
+ problem = "Your sign-in ended, so the rest weren't sent. Sign in again to continue."
394
+ break
395
+ except account.ToolError as exc:
396
+ problem = f"Items {start + 1} onwards weren't sent: {exc}"
397
+ break
398
+ created = result.get("submissions") or []
399
+ sent += len(created)
400
+ duplicates += sum(1 for s in created if s.get("status") == "rejected" and s.get("duplicateOfSubmissionId"))
401
+ if result.get("stoppedAtCapacity"):
402
+ problem = "The pool filled up, so the rest weren't sent."
403
+ break
404
+ return _ok({"sent": sent, "duplicates": duplicates, "skipped": skipped, "problem": problem, "karma": contract_data["karma"]})
405
+
406
+
407
+ # --- Submissions ------------------------------------------------------------------------------------------
408
+
409
+
410
+ @router.get("/submissions")
411
+ def submissions(request: Request):
412
+ _, session = _session(request)
413
+ data = _call(session, "list_my_submissions", {"limit": 50}) or {}
414
+ rows = [contribute.submission_row(item) for item in data.get("items") or []]
415
+ return _ok({"items": rows, "total": data.get("total") if isinstance(data.get("total"), int) else len(rows)})
416
+
417
+
418
+ @router.get("/submissions/{submission_id}")
419
+ def submission(submission_id: str, request: Request):
420
+ _, session = _session(request)
421
+ return _ok(contribute.submission_view(_call(session, "check_submission", {"submissionId": submission_id}), db.APP_URL))
422
+
423
+
424
+ # --- Reviewing --------------------------------------------------------------------------------------
425
+ # A review is an audit: a few items from one pool, held by one reviewer for 24 hours.
426
+
427
+
428
+ def _reviewer(request: Request) -> account.Session:
429
+ _, session = _session(request)
430
+ if "validate" not in session.scope.split():
431
+ raise ApiError(403, "The Space isn't allowed to review for you. Sign out, then sign in again and keep validate ticked.")
432
+ return session
433
+
434
+
435
+ @router.get("/audits")
436
+ def audits(request: Request, q: str = ""):
437
+ session = _reviewer(request)
438
+ search = " ".join(q.split())[:120]
439
+ data = _call(session, "list_audits", {"limit": 50, **({"search": search} if search else {})}) or {}
440
+ rows = [validate.audit_row(a) for a in data.get("audits") or []]
441
+ return _ok({
442
+ "audits": rows,
443
+ "total": data.get("total") if isinstance(data.get("total"), int) else len(rows),
444
+ # Audits holding the reviewer's own work, which they can't review.
445
+ "hidden": data.get("conflictExcluded") if isinstance(data.get("conflictExcluded"), int) else 0,
446
+ })
447
+
448
+
449
+ @router.get("/audits/mine")
450
+ def my_audits(request: Request, status: str = "claimed"):
451
+ session = _reviewer(request)
452
+ status = status if status in ("claimed", "overdue_review", "completed") else "claimed"
453
+ data = _call(session, "list_my_audits", {"status": status, "limit": 50}) or {}
454
+ rows = [validate.audit_row(a) for a in data.get("audits") or []]
455
+ return _ok({"audits": rows, "total": data.get("total") if isinstance(data.get("total"), int) else len(rows)})
456
+
457
+
458
+ @router.post("/audits/{audit_id}/claim")
459
+ def claim_audit(audit_id: str, request: Request):
460
+ session = _reviewer(request)
461
+ window = (_call(session, "claim_audit", {"windowId": audit_id}) or {}).get("window") or {}
462
+ return _ok({"id": window.get("id") or audit_id, "deadline": window.get("claimExpiresAt")})
463
+
464
+
465
+ @router.get("/audits/{audit_id}")
466
+ def audit(audit_id: str, request: Request):
467
+ session = _reviewer(request)
468
+ try:
469
+ detail = account.call_tool(session, "get_audit", {"windowId": audit_id})
470
+ except account.NotSignedIn as exc:
471
+ raise ApiError(401, "Your sign-in ended. Sign in again to continue.") from exc
472
+ except account.ToolError as exc:
473
+ # The items stay hidden until the reviewer holds the audit (DataBounty's rule).
474
+ if "claim_audit" in str(exc) or "Claim this audit" in str(exc):
475
+ raise ApiError(409, "Claim this audit to see its items. It's yours for 24 hours.", needs="claim") from exc
476
+ raise ApiError(400, str(exc)) from exc
477
+ return _ok(validate.audit_view(detail or {}, db.APP_URL))
478
+
479
+
480
+ @router.post("/audits/{audit_id}/decisions")
481
+ async def decide(audit_id: str, request: Request):
482
+ session = _reviewer(request)
483
+ decision, problem = validate.decision(await _json(request))
484
+ if problem:
485
+ raise ApiError(400, problem)
486
+ result = _call(session, "submit_decisions", {"windowId": audit_id, "decisions": [decision]}) or {}
487
+ return _ok({"ok": True, "decided": result.get("decisionsCount", 1)})
488
+
489
+
490
+ # --- Sponsoring ----------------------------------------------------------------------------------------
491
+ # Only a pool's requester can see its items (DataBounty's rule), so the page asks on every pool and
492
+ # shows the panel only when DataBounty says yes. Requesting a dataset stays on databounty.io.
493
+
494
+
495
+ def _sponsor(request: Request) -> account.Session:
496
+ _, session = _session(request)
497
+ if "sponsor" not in session.scope.split():
498
+ raise ApiError(403, "Sign out, then sign in again and keep sponsor ticked, to see your pools here.", needs="sponsor_scope")
499
+ return session
500
+
501
+
502
+ def _sponsor_call(session: account.Session, name: str, arguments: dict[str, Any]) -> Any:
503
+ try:
504
+ return account.call_tool(session, name, arguments)
505
+ except account.NotSignedIn as exc:
506
+ raise ApiError(401, "Your sign-in ended. Sign in again to continue.") from exc
507
+ except account.ToolError as exc:
508
+ message = str(exc)
509
+ if "owner" in message.lower() or "forbidden" in message.lower():
510
+ raise ApiError(403, "Only the person who requested this pool can see its items.", needs="not_sponsor") from exc
511
+ raise ApiError(400, message) from exc
512
+
513
+
514
+ @router.get("/pools/{pool_id}/sponsor")
515
+ def sponsor_pool(pool_id: str, request: Request, status: str = "", q: str = ""):
516
+ session = _sponsor(request)
517
+ arguments: dict[str, Any] = {"bountyId": pool_id, "limit": 50}
518
+ if status.strip():
519
+ arguments["status"] = status.strip()
520
+ if q.strip():
521
+ arguments["search"] = " ".join(q.split())[:120]
522
+ data = _sponsor_call(session, "get_sponsor_submission_evidence", arguments) or {}
523
+ try:
524
+ specs = (db.pool_details(pool_id) or {}).get("fields") or []
525
+ except (ValueError, db.UpstreamError):
526
+ specs = [] # labels are a nicety; the items still show under their keys
527
+ return _ok(sponsor.pool_view(data, specs))
528
+
529
+
530
+ @router.post("/sponsor/submissions/{submission_id}/dispute")
531
+ async def sponsor_dispute(submission_id: str, request: Request):
532
+ session = _sponsor(request)
533
+ arguments, problem = sponsor.dispute(await _json(request))
534
+ if problem:
535
+ raise ApiError(400, problem)
536
+ _sponsor_call(session, "dispute_accepted_submission", {"submissionId": submission_id, **arguments})
537
+ return _ok({"ok": True})
538
+
539
+
540
+ # --- Requesting a dataset (sponsors) -------------------------------------------------------------------
541
+ # The request goes to DataBounty's request_dataset tool, which checks it again; an admin reviews it,
542
+ # and its reference samples, before a pool opens. Until DataBounty has that tool, the page links to
543
+ # databounty.io instead (`open` below).
544
+
545
+ SAMPLE_TYPES = {".json": "application/json", ".jsonl": "application/x-ndjson", ".ndjson": "application/x-ndjson"}
546
+
547
+
548
+ def _request_options() -> dict[str, Any]:
549
+ return requesting.catalog_view(_public(db.request_catalog))
550
+
551
+
552
+ @router.get("/requesting")
553
+ def requesting_options():
554
+ view = _request_options()
555
+ try:
556
+ view["open"] = db.requesting_open()
557
+ except db.UpstreamError:
558
+ view["open"] = False # can't tell: keep the link to databounty.io
559
+ return _ok(view)
560
+
561
+
562
+ @router.get("/requesting/{type_id}/example.json")
563
+ def example_template(type_id: str):
564
+ """An empty example item for a dataset type, to fill in and attach to a request."""
565
+ t = next((t for t in _request_options()["types"] if t["id"] == type_id), None)
566
+ if not t:
567
+ raise ApiError(404, "That dataset type isn't open for requests.")
568
+ body = json.dumps({key: "" for key in t["fields"]}, indent=2) + "\n"
569
+ slug = re.sub(r"[^a-z0-9_-]+", "-", type_id.lower()).strip("-") or "dataset"
570
+ return Response(
571
+ body,
572
+ media_type="application/json",
573
+ headers={"Content-Disposition": f'attachment; filename="{slug}-example.json"', **NO_STORE},
574
+ )
575
+
576
+
577
+ @router.post("/requests")
578
+ async def file_request(request: Request):
579
+ session = _sponsor(request)
580
+ types = {t["id"]: t for t in _request_options()["types"]}
581
+ arguments, problem = requesting.request_arguments(await _json(request), types)
582
+ if problem:
583
+ raise ApiError(400, problem)
584
+ result = _sponsor_call(session, "request_dataset", arguments) or {}
585
+ filed = result.get("request") or {}
586
+ return _ok({"id": filed.get("id"), "alreadyFiled": bool(result.get("alreadyFiled"))})
587
+
588
+
589
+ @router.get("/requests/mine")
590
+ def my_requests(request: Request):
591
+ session = _sponsor(request)
592
+ data = _sponsor_call(session, "list_my_dataset_requests", {"limit": 50}) or {}
593
+ try:
594
+ names = {t["id"]: t["name"] for t in _request_options()["types"]}
595
+ except ApiError:
596
+ names = {} # names are a nicety; the requests still show
597
+ rows = [requesting.request_row(r, names) for r in data.get("requests") or [] if isinstance(r, dict)]
598
+ return _ok({"requests": rows, "more": bool(data.get("nextCursor"))})
599
+
600
+
601
+ @router.post("/requests/{request_id}/samples")
602
+ async def add_sample(request_id: str, request: Request):
603
+ session = _sponsor(request)
604
+ async with request.form() as form:
605
+ upload = form.get("file")
606
+ if upload is None or not getattr(upload, "filename", ""):
607
+ raise ApiError(400, "Choose a file with an example item.")
608
+ path, filename, content_type = await _save(upload)
609
+ try:
610
+ # Browsers rarely name a JSON Lines file's type; DataBounty checks the pair.
611
+ content_type = SAMPLE_TYPES.get(Path(filename).suffix.lower(), content_type)
612
+ account.upload_sample(session, request_id, path, filename, content_type)
613
+ except account.NotSignedIn as exc:
614
+ raise ApiError(401, "Your sign-in ended. Sign in again to continue.") from exc
615
+ except account.ToolError as exc:
616
+ raise ApiError(400, f"{filename}: {exc}") from exc
617
+ finally:
618
+ Path(path).unlink(missing_ok=True)
619
+ return _ok({"ok": True})