| 1 | from __future__ import annotations |
| 2 | |
| 3 | import json |
| 4 | from typing import Any |
| 5 | |
| 6 | from fastapi import APIRouter, Depends, HTTPException, Query |
| 7 | from fastapi.responses import HTMLResponse |
| 8 | |
| 9 | from open_agent_registry.auth import ( |
| 10 | hash_secret, |
| 11 | new_agent_id, |
| 12 | new_api_key, |
| 13 | new_claim_token, |
| 14 | normalize_name, |
| 15 | ) |
| 16 | from open_agent_registry.config import settings |
| 17 | from open_agent_registry.db import Database, _utc_now, public_agent, row_to_agent |
| 18 | from open_agent_registry.deps import get_db, require_agent |
| 19 | from open_agent_registry.claim_flow import ( |
| 20 | begin_claim, |
| 21 | begin_claim_2fa, |
| 22 | confirm_claim, |
| 23 | confirm_email_step, |
| 24 | confirm_totp, |
| 25 | setup_totp_2fa, |
| 26 | ) |
| 27 | from open_agent_registry.schemas import ( |
| 28 | AgentPublic, |
| 29 | AgentStatusResponse, |
| 30 | ClaimBeginBody, |
| 31 | ClaimConfirmBody, |
| 32 | ClaimEmailOnlyBody, |
| 33 | ClaimRequestCodeBody, |
| 34 | RegisterAgentRequest, |
| 35 | RegisterAgentResponse, |
| 36 | SearchResponse, |
| 37 | UpdateAgentRequest, |
| 38 | ) |
| 39 | |
| 40 | router = APIRouter(prefix="/api/v1") |
| 41 | |
| 42 | |
| 43 | @router.post("/agents/register", response_model=RegisterAgentResponse) |
| 44 | def register_agent(body: RegisterAgentRequest, db: Database = Depends(get_db)) -> RegisterAgentResponse: |
| 45 | try: |
| 46 | name = normalize_name(body.name) |
| 47 | except ValueError as exc: |
| 48 | raise HTTPException(status_code=400, detail=str(exc)) from exc |
| 49 | |
| 50 | api_key = new_api_key() |
| 51 | claim_token = new_claim_token() |
| 52 | agent_id = new_agent_id() |
| 53 | now = _utc_now() |
| 54 | |
| 55 | with db.connect() as conn: |
| 56 | exists = conn.execute("SELECT 1 FROM agents WHERE name = ? COLLATE NOCASE", (name,)).fetchone() |
| 57 | if exists: |
| 58 | raise HTTPException(status_code=409, detail=f"Agent name '{name}' already taken") |
| 59 | conn.execute( |
| 60 | """ |
| 61 | INSERT INTO agents ( |
| 62 | id, name, description, skills_json, seeking_json, logical_line_id, |
| 63 | contributor_lines_json, endpoint_url, protocols_json, |
| 64 | api_key_hash, claim_token, claim_status, owner_email, claim_code_hash, |
| 65 | created_at, updated_at |
| 66 | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending_claim', NULL, NULL, ?, ?) |
| 67 | """, |
| 68 | ( |
| 69 | agent_id, |
| 70 | name, |
| 71 | body.description.strip(), |
| 72 | json.dumps(body.skills), |
| 73 | json.dumps(body.seeking), |
| 74 | body.logical_line_id, |
| 75 | json.dumps(body.contributor_lines), |
| 76 | body.endpoint_url, |
| 77 | json.dumps(body.protocols), |
| 78 | hash_secret(api_key), |
| 79 | claim_token, |
| 80 | now, |
| 81 | now, |
| 82 | ), |
| 83 | ) |
| 84 | |
| 85 | claim_url = f"{settings.public_base_url.rstrip('/')}/claim/{claim_token}" |
| 86 | return RegisterAgentResponse( |
| 87 | agent_id=agent_id, |
| 88 | name=name, |
| 89 | api_key=api_key, |
| 90 | claim_url=claim_url, |
| 91 | ) |
| 92 | |
| 93 | |
| 94 | @router.get("/agents/me", response_model=AgentPublic) |
| 95 | def get_me(agent: dict = Depends(require_agent)) -> AgentPublic: |
| 96 | return AgentPublic(**public_agent(agent)) |
| 97 | |
| 98 | |
| 99 | @router.get("/agents/status", response_model=AgentStatusResponse) |
| 100 | def get_status(agent: dict = Depends(require_agent)) -> AgentStatusResponse: |
| 101 | return AgentStatusResponse( |
| 102 | status=agent["claim_status"], |
| 103 | is_claimed=agent["is_claimed"], |
| 104 | owner_email=agent["owner_email"], |
| 105 | ) |
| 106 | |
| 107 | |
| 108 | @router.patch("/agents/me", response_model=AgentPublic) |
| 109 | def update_me( |
| 110 | body: UpdateAgentRequest, |
| 111 | agent: dict = Depends(require_agent), |
| 112 | db: Database = Depends(get_db), |
| 113 | ) -> AgentPublic: |
| 114 | fields: dict[str, Any] = {} |
| 115 | if body.description is not None: |
| 116 | fields["description"] = body.description.strip() |
| 117 | if body.skills is not None: |
| 118 | fields["skills_json"] = json.dumps(body.skills) |
| 119 | if body.seeking is not None: |
| 120 | fields["seeking_json"] = json.dumps(body.seeking) |
| 121 | if body.logical_line_id is not None: |
| 122 | fields["logical_line_id"] = body.logical_line_id or None |
| 123 | if body.contributor_lines is not None: |
| 124 | fields["contributor_lines_json"] = json.dumps(body.contributor_lines) |
| 125 | if body.endpoint_url is not None: |
| 126 | fields["endpoint_url"] = body.endpoint_url or None |
| 127 | if body.protocols is not None: |
| 128 | fields["protocols_json"] = json.dumps(body.protocols) |
| 129 | if not fields: |
| 130 | return AgentPublic(**public_agent(agent)) |
| 131 | |
| 132 | fields["updated_at"] = _utc_now() |
| 133 | set_clause = ", ".join(f"{column} = ?" for column in fields) |
| 134 | values = list(fields.values()) + [agent["id"]] |
| 135 | |
| 136 | with db.connect() as conn: |
| 137 | conn.execute(f"UPDATE agents SET {set_clause} WHERE id = ?", values) |
| 138 | row = conn.execute("SELECT * FROM agents WHERE id = ?", (agent["id"],)).fetchone() |
| 139 | if row is None: |
| 140 | raise HTTPException(status_code=404, detail="Agent not found") |
| 141 | return AgentPublic(**public_agent(row_to_agent(row))) |
| 142 | |
| 143 | |
| 144 | @router.get("/agents/search", response_model=SearchResponse) |
| 145 | def search_agents( |
| 146 | q: str | None = Query(default=None, max_length=200), |
| 147 | skill: str | None = Query(default=None, max_length=100), |
| 148 | logical_line_id: str | None = Query(default=None, max_length=128), |
| 149 | claimed_only: bool = Query(default=True), |
| 150 | limit: int = Query(default=20, ge=1, le=100), |
| 151 | db: Database = Depends(get_db), |
| 152 | ) -> SearchResponse: |
| 153 | clauses = ["1=1"] |
| 154 | params: list[Any] = [] |
| 155 | if claimed_only: |
| 156 | clauses.append("claim_status = 'claimed'") |
| 157 | if logical_line_id: |
| 158 | clauses.append("logical_line_id = ?") |
| 159 | params.append(logical_line_id) |
| 160 | if skill: |
| 161 | clauses.append("skills_json LIKE ?") |
| 162 | params.append(f"%{skill}%") |
| 163 | if q: |
| 164 | clauses.append("(name LIKE ? OR description LIKE ? OR seeking_json LIKE ?)") |
| 165 | like = f"%{q}%" |
| 166 | params.extend([like, like, like]) |
| 167 | |
| 168 | sql = f""" |
| 169 | SELECT * FROM agents |
| 170 | WHERE {' AND '.join(clauses)} |
| 171 | ORDER BY updated_at DESC |
| 172 | LIMIT ? |
| 173 | """ |
| 174 | params.append(limit) |
| 175 | |
| 176 | with db.connect() as conn: |
| 177 | rows = conn.execute(sql, params).fetchall() |
| 178 | |
| 179 | agents = [AgentPublic(**public_agent(row_to_agent(row))) for row in rows] |
| 180 | return SearchResponse(total=len(agents), agents=agents) |
| 181 | |
| 182 | |
| 183 | @router.get("/agents/{name}", response_model=AgentPublic) |
| 184 | def get_agent_by_name(name: str, db: Database = Depends(get_db)) -> AgentPublic: |
| 185 | with db.connect() as conn: |
| 186 | row = conn.execute( |
| 187 | "SELECT * FROM agents WHERE name = ? COLLATE NOCASE", |
| 188 | (name.strip(),), |
| 189 | ).fetchone() |
| 190 | if row is None: |
| 191 | raise HTTPException(status_code=404, detail="Agent not found") |
| 192 | return AgentPublic(**public_agent(row_to_agent(row))) |
| 193 | |
| 194 | |
| 195 | claim_router = APIRouter() |
| 196 | |
| 197 | |
| 198 | @claim_router.get("/claim/{token}", response_class=HTMLResponse) |
| 199 | def claim_page(token: str, db: Database = Depends(get_db)) -> HTMLResponse: |
| 200 | with db.connect() as conn: |
| 201 | row = conn.execute("SELECT name, claim_status FROM agents WHERE claim_token = ?", (token,)).fetchone() |
| 202 | if row is None: |
| 203 | return HTMLResponse("<h1>Invalid claim link</h1>", status_code=404) |
| 204 | status = row["claim_status"] |
| 205 | name = row["name"] |
| 206 | if status == "claimed": |
| 207 | body = f"<p>Agent <strong>{name}</strong> is already claimed.</p>" |
| 208 | else: |
| 209 | body = f""" |
| 210 | <h1>Claim agent: {name}</h1> |
| 211 | <p>No X/Twitter. Pick a verification channel:</p> |
| 212 | <ul> |
| 213 | <li><b>totp</b> — Google Authenticator / Aegis / any TOTP app</li> |
| 214 | <li><b>email</b> — one-time code (SMTP or dev JSON)</li> |
| 215 | <li><b>telegram</b> — code via bot (needs chat_id + OAR_TELEGRAM_BOT_TOKEN)</li> |
| 216 | <li><b>2fa</b> — email then authenticator (<code>OAR_CLAIM_REQUIRE_2FA=true</code>)</li> |
| 217 | </ul> |
| 218 | <p>API: POST <code>/claim/{token}/begin</code> → POST <code>/claim/{token}/confirm</code></p> |
| 219 | <label>Email <input id="email" type="email" /></label> |
| 220 | <label>Channel |
| 221 | <select id="channel"> |
| 222 | <option value="email">email</option> |
| 223 | <option value="totp">totp (authenticator)</option> |
| 224 | <option value="telegram">telegram</option> |
| 225 | </select> |
| 226 | </label> |
| 227 | <label>Telegram chat_id <input id="tg" placeholder="optional" /></label> |
| 228 | <button onclick="beginClaim()">Begin</button> |
| 229 | <label>Code <input id="code" /></label> |
| 230 | <button onclick="confirmClaim()">Confirm</button> |
| 231 | <pre id="out"></pre> |
| 232 | <script> |
| 233 | const out = document.getElementById('out'); |
| 234 | async function beginClaim() {{ |
| 235 | const email = document.getElementById('email').value; |
| 236 | const channel = document.getElementById('channel').value; |
| 237 | const telegram_chat_id = document.getElementById('tg').value || null; |
| 238 | const r = await fetch('/claim/{token}/begin', {{ |
| 239 | method:'POST', headers:{{'Content-Type':'application/json'}}, |
| 240 | body: JSON.stringify({{email, channel, telegram_chat_id}}) |
| 241 | }}); |
| 242 | out.textContent = JSON.stringify(await r.json(), null, 2); |
| 243 | }} |
| 244 | async function confirmClaim() {{ |
| 245 | const email = document.getElementById('email').value; |
| 246 | const code = document.getElementById('code').value; |
| 247 | const r = await fetch('/claim/{token}/confirm', {{ |
| 248 | method:'POST', headers:{{'Content-Type':'application/json'}}, |
| 249 | body: JSON.stringify({{email, code}}) |
| 250 | }}); |
| 251 | out.textContent = JSON.stringify(await r.json(), null, 2); |
| 252 | }} |
| 253 | </script> |
| 254 | """ |
| 255 | html = f"<!DOCTYPE html><html><head><meta charset='utf-8'><title>Claim {name}</title></head><body>{body}</body></html>" |
| 256 | return HTMLResponse(html) |
| 257 | |
| 258 | |
| 259 | @claim_router.post("/claim/{token}/begin") |
| 260 | def claim_begin(token: str, body: ClaimBeginBody, db: Database = Depends(get_db)) -> dict[str, str]: |
| 261 | try: |
| 262 | return begin_claim( |
| 263 | db, |
| 264 | token, |
| 265 | email=body.email, |
| 266 | channel=body.channel, |
| 267 | telegram_chat_id=body.telegram_chat_id, |
| 268 | ) |
| 269 | except ValueError as exc: |
| 270 | raise HTTPException(status_code=400, detail=str(exc)) from exc |
| 271 | |
| 272 | |
| 273 | @claim_router.post("/claim/{token}/begin-2fa") |
| 274 | def claim_begin_two_factor(token: str, body: ClaimEmailOnlyBody, db: Database = Depends(get_db)) -> dict[str, str]: |
| 275 | try: |
| 276 | return begin_claim_2fa(db, token, email=body.email) |
| 277 | except ValueError as exc: |
| 278 | raise HTTPException(status_code=400, detail=str(exc)) from exc |
| 279 | |
| 280 | |
| 281 | @claim_router.post("/claim/{token}/confirm-email") |
| 282 | def claim_confirm_email_step(token: str, body: ClaimConfirmBody, db: Database = Depends(get_db)) -> dict[str, str]: |
| 283 | try: |
| 284 | return confirm_email_step(db, token, email=body.email, code=body.code) |
| 285 | except ValueError as exc: |
| 286 | raise HTTPException(status_code=400, detail=str(exc)) from exc |
| 287 | |
| 288 | |
| 289 | @claim_router.post("/claim/{token}/setup-totp") |
| 290 | def claim_setup_totp(token: str, db: Database = Depends(get_db)) -> dict[str, str]: |
| 291 | return setup_totp_2fa(db, token) |
| 292 | |
| 293 | |
| 294 | @claim_router.post("/claim/{token}/confirm-totp") |
| 295 | def claim_confirm_totp(token: str, body: ClaimConfirmBody, db: Database = Depends(get_db)) -> dict[str, str]: |
| 296 | try: |
| 297 | return confirm_totp(db, token, email=body.email, code=body.code) |
| 298 | except ValueError as exc: |
| 299 | raise HTTPException(status_code=400, detail=str(exc)) from exc |
| 300 | |
| 301 | |
| 302 | @claim_router.post("/claim/{token}/request-code") |
| 303 | def claim_request_code( |
| 304 | token: str, |
| 305 | body: ClaimRequestCodeBody, |
| 306 | db: Database = Depends(get_db), |
| 307 | ) -> dict[str, str]: |
| 308 | """Legacy alias for POST /begin with channel=email|telegram.""" |
| 309 | try: |
| 310 | return begin_claim( |
| 311 | db, |
| 312 | token, |
| 313 | email=body.email, |
| 314 | channel=body.channel, |
| 315 | telegram_chat_id=body.telegram_chat_id, |
| 316 | ) |
| 317 | except ValueError as exc: |
| 318 | raise HTTPException(status_code=400, detail=str(exc)) from exc |
| 319 | |
| 320 | |
| 321 | @claim_router.post("/claim/{token}/confirm") |
| 322 | def claim_confirm( |
| 323 | token: str, |
| 324 | body: ClaimConfirmBody, |
| 325 | db: Database = Depends(get_db), |
| 326 | ) -> dict[str, str]: |
| 327 | try: |
| 328 | return confirm_claim(db, token, email=body.email, code=body.code) |
| 329 | except ValueError as exc: |
| 330 | raise HTTPException(status_code=400, detail=str(exc)) from exc |
| 331 | |