Forge
pythonb1bb0b24
1from __future__ import annotations
2
3import json
4from typing import Any
5
6from fastapi import APIRouter, Depends, HTTPException, Query
7from fastapi.responses import HTMLResponse
8
9from open_agent_registry.auth import (
10 hash_secret,
11 new_agent_id,
12 new_api_key,
13 new_claim_token,
14 normalize_name,
15)
16from open_agent_registry.config import settings
17from open_agent_registry.db import Database, _utc_now, public_agent, row_to_agent
18from open_agent_registry.deps import get_db, require_agent
19from 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)
27from 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
40router = APIRouter(prefix="/api/v1")
41
42
43@router.post("/agents/register", response_model=RegisterAgentResponse)
44def 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)
95def get_me(agent: dict = Depends(require_agent)) -> AgentPublic:
96 return AgentPublic(**public_agent(agent))
97
98
99@router.get("/agents/status", response_model=AgentStatusResponse)
100def 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)
109def 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)
145def 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)
184def 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
195claim_router = APIRouter()
196
197
198@claim_router.get("/claim/{token}", response_class=HTMLResponse)
199def 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")
260def 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")
274def 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")
282def 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")
290def 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")
295def 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")
303def 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")
322def 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
View only · write via MCP/CIDE