1use askama::Template;
12use axum::Router;
13use axum::extract::{Path, State};
14use axum::http::{StatusCode, header};
15use axum::response::{AppendHeaders, IntoResponse, Response};
16use axum::routing::get;
17use codoseo_core::plan::PlanLimits;
18use codoseo_store::accounts::{SignIn, SignInOutcome};
19use codoseo_store::api_keys::{self, CreateKeyOutcome};
20use codoseo_store::auth::{
21 TokenPurpose, consume_token, live_token_payload, token_is_live, unconsume_token,
22};
23use codoseo_store::events::{self, EventKind};
24use codoseo_store::sites::{self, CreateOutcome, Site};
25use serde_json::json;
26
27use super::quick::require_cloud;
28use super::sites::{FIRST_CRAWL_PRIORITY, schedule_for};
29use crate::agent::keys;
30use crate::auth::{CurrentUser, email, magic, session, signup_policy};
31use crate::error::AppError;
32use crate::render::html;
33use crate::state::AppState;
34
35pub fn routes() -> Router<AppState> {
36 Router::new()
37 .route("/monitoring/resume/{token}", get(confirm).post(resume))
38 .route("/monitoring/start/{token}", get(start_confirm).post(start))
39}
40
41#[derive(Template)]
42#[template(path = "monitoring/resume.html")]
43struct ResumePage {
44 view: ResumeView,
46 token: String,
47}
48
49enum ResumeView {
50 Confirm,
51 Resumed,
52 Expired,
53}
54
55async fn confirm(
58 State(state): State<AppState>,
59 Path(token): Path<String>,
60) -> Result<Response, AppError> {
61 let live = token_is_live(
62 &state.pool,
63 TokenPurpose::ResumeMonitoring,
64 &session::hash(&token),
65 )
66 .await?;
67 if live {
68 return Ok(html(&ResumePage {
69 view: ResumeView::Confirm,
70 token,
71 })?
72 .into_response());
73 }
74 Ok((
75 StatusCode::GONE,
76 html(&ResumePage {
77 view: ResumeView::Expired,
78 token,
79 })?,
80 )
81 .into_response())
82}
83
84async fn resume(
85 State(state): State<AppState>,
86 Path(token): Path<String>,
87) -> Result<Response, AppError> {
88 let mut tx = state.pool.begin().await?;
90 let account = consume_token(
91 &mut *tx,
92 TokenPurpose::ResumeMonitoring,
93 &session::hash(&token),
94 )
95 .await?
96 .and_then(|used| used.account_id);
97 let Some(account) = account else {
98 return Ok((
99 StatusCode::GONE,
100 html(&ResumePage {
101 view: ResumeView::Expired,
102 token,
103 })?,
104 )
105 .into_response());
106 };
107 codoseo_store::accounts::resume_monitoring(&mut *tx, account).await?;
108 tx.commit().await?;
109 Ok(html(&ResumePage {
110 view: ResumeView::Resumed,
111 token,
112 })?
113 .into_response())
114}
115
116pub enum StartView {
120 Confirm {
122 domain: String,
123 email: String,
124 },
125 Expired {
128 signed_in: bool,
129 },
130 Done(Box<StartDone>),
131}
132
133pub struct StartDone {
135 pub domain: String,
136 pub email: String,
137 pub site: SiteNote,
138 pub audit_href: Option<String>,
140 pub key: Option<MintedKey>,
142 pub key_limit: i64,
143 pub mcp_url: String,
144}
145
146pub enum SiteNote {
147 Added,
149 Existing,
151 PlanFull { max_sites: u32 },
153}
154
155pub struct MintedKey {
157 pub key: String,
158 pub command: String,
160 pub json: String,
162}
163
164#[derive(Template)]
165#[template(path = "monitoring/start.html")]
166struct StartPage {
167 view: StartView,
168 token: String,
169}
170
171async fn start_confirm(
174 State(state): State<AppState>,
175 user: Option<CurrentUser>,
176 Path(token): Path<String>,
177) -> Result<Response, AppError> {
178 require_cloud(&state)?;
179 let payload = live_token_payload(
180 &state.pool,
181 TokenPurpose::StartMonitoring,
182 &session::hash(&token),
183 )
184 .await?;
185 let Some(payload) = payload else {
186 return expired(token, user.is_some());
187 };
188 let text = |k: &str| payload[k].as_str().unwrap_or_default().to_owned();
189 let page = StartPage {
190 view: StartView::Confirm {
191 domain: text("domain"),
192 email: text("email"),
193 },
194 token,
195 };
196 Ok(([(header::CACHE_CONTROL, "no-store")], html(&page)?).into_response())
197}
198
199fn expired(token: String, signed_in: bool) -> Result<Response, AppError> {
200 Ok((
201 StatusCode::GONE,
202 [(header::CACHE_CONTROL, "no-store")],
203 html(&StartPage {
204 view: StartView::Expired { signed_in },
205 token,
206 })?,
207 )
208 .into_response())
209}
210
211async fn start(
216 State(state): State<AppState>,
217 user: Option<CurrentUser>,
218 Path(token): Path<String>,
219) -> Result<Response, AppError> {
220 require_cloud(&state)?;
221 let hash = session::hash(&token);
222 let used = consume_token(&state.pool, TokenPurpose::StartMonitoring, &hash).await?;
223 let Some(payload) = used.and_then(|u| u.payload) else {
224 return expired(token, user.is_some());
225 };
226 match confirm_start(&state, token, &payload).await {
227 Ok(response) => Ok(response),
228 Err(error) => {
229 tracing::error!(%error, "start-monitoring confirmation failed; making its link usable again");
230 if let Err(e) = unconsume_token(&state.pool, TokenPurpose::StartMonitoring, &hash).await
231 {
232 tracing::error!(error = %e, "could not make the start-monitoring link usable again");
233 }
234 Err(error)
235 }
236 }
237}
238
239async fn confirm_start(
240 state: &AppState,
241 token: String,
242 payload: &serde_json::Value,
243) -> Result<Response, AppError> {
244 let text = |k: &str| -> Result<String, AppError> {
245 payload[k]
246 .as_str()
247 .map(str::to_owned)
248 .ok_or_else(|| AppError::internal(format!("start-monitoring token without {k}")))
249 };
250 let (address, start_url) = (text("email")?, text("start_url")?);
251 let domain = text("domain")?.trim_end_matches('.').to_owned();
253
254 let canonical = email::canonical(&address);
255 let outcome = codoseo_store::accounts::sign_in(
256 &state.pool,
257 &SignIn {
258 email: &address,
259 canonical: &canonical,
260 github_id: None,
261 },
262 signup_policy(state),
263 )
264 .await?;
265 let account = match outcome {
266 SignInOutcome::Existing(a) | SignInOutcome::Created(a) => a,
267 SignInOutcome::SignupsClosed => return Err(magic::signups_closed()),
268 };
269 codoseo_store::accounts::record_email_click(&state.pool, account.id).await?;
271
272 let max_sites = PlanLimits::for_plan(account.plan).max_sites;
273 let owned = |sites: &[Site]| sites.iter().find(|s| s.domain == domain).cloned();
274 let mut site = owned(&sites::list_for_account(&state.pool, account.id).await?);
275 let note = match site {
276 Some(_) => SiteNote::Existing,
277 None => {
278 let created = sites::create_checked(
279 &state.pool,
280 account.id,
281 &domain,
282 &start_url,
283 schedule_for(account.plan),
284 max_sites.map(i64::from),
285 FIRST_CRAWL_PRIORITY,
286 Some("agent"),
288 )
289 .await?;
290 match created {
291 CreateOutcome::Created(new) => {
292 super::settings_alerts::default_rules_for_site(state, account.id, new.id).await;
293 site = Some(new);
294 SiteNote::Added
295 }
296 CreateOutcome::Duplicate => {
298 site = owned(&sites::list_for_account(&state.pool, account.id).await?);
299 SiteNote::Existing
300 }
301 CreateOutcome::LimitReached => SiteNote::PlanFull {
302 max_sites: max_sites.unwrap_or_default(),
303 },
304 }
305 }
306 };
307 events::record(
308 &state.pool,
309 EventKind::LinkClicked,
310 Some(account.id),
311 site.as_ref().map(|s| s.id),
312 Some(json!({
313 "source": "agent",
314 "domain": domain,
315 "outcome": match note {
316 SiteNote::Added => "added",
317 SiteNote::Existing => "existing",
318 SiteNote::PlanFull { .. } => "limit",
319 },
320 })),
321 )
322 .await?;
323
324 let mcp_url = keys::mcp_url(&state.config)?;
325 let cookie = session::start(state, account.id).await?;
328 let key = keys::generate();
329 let created = api_keys::create(
330 &state.pool,
331 account.id,
332 "Agent (start_monitoring)",
333 &key.hash,
334 &key.prefix,
335 api_keys::MAX_LIVE_KEYS,
336 )
337 .await?;
338 let new_key_id = match &created {
339 CreateKeyOutcome::Created(k) => Some(k.id),
340 CreateKeyOutcome::LimitReached => None,
341 };
342 let shown = new_key_id.map(|_| MintedKey {
343 command: keys::claude_command(&mcp_url, &key.plaintext),
344 json: keys::mcp_servers_json(&mcp_url, &key.plaintext),
345 key: key.plaintext,
346 });
347
348 let page = StartPage {
349 view: StartView::Done(Box::new(StartDone {
350 audit_href: site.as_ref().map(|s| format!("/s/{}/audit", s.id)),
351 domain,
352 email: address,
353 site: note,
354 key: shown,
355 key_limit: api_keys::MAX_LIVE_KEYS,
356 mcp_url,
357 })),
358 token,
359 };
360 let body = match html(&page) {
361 Ok(body) => body,
362 Err(error) => {
363 if let Some(id) = new_key_id
365 && let Err(e) = api_keys::revoke(&state.pool, account.id, id).await
366 {
367 tracing::error!(error = %e, "could not revoke a key that was never shown");
368 }
369 return Err(error);
370 }
371 };
372 Ok((
374 AppendHeaders([
375 (header::SET_COOKIE, cookie),
376 (
377 header::CACHE_CONTROL,
378 axum::http::HeaderValue::from_static("no-store"),
379 ),
380 ]),
381 body,
382 )
383 .into_response())
384}