Skip to main content

codoseo_web/routes/
monitoring.rs

1//! Two emailed links that act on a person's behalf, and only once they press a button (mail
2//! scanners and link previews open every link, so a GET must change nothing):
3//!
4//! * `/monitoring/start/{token}` (cloud only): the link the no-key MCP tool `start_monitoring`
5//!   emails. The POST signs the person in (creating a Free account when the address has none),
6//!   adds the site with its weekly crawls and first crawl, and shows an API key once.
7//! * `/monitoring/resume/{token}`: the link in the "Keep monitoring?" email. It does not sign the
8//!   visitor in; the token is the only credential, it works once, and all it can do is un-pause
9//!   the account it was issued for.
10
11use 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    /// `Confirm` (the button), `Resumed` (done) or `Expired`.
45    view: ResumeView,
46    token: String,
47}
48
49enum ResumeView {
50    Confirm,
51    Resumed,
52    Expired,
53}
54
55/// The page behind the emailed link: a button when the token is still good, a friendly dead
56/// end otherwise. Reads only.
57async 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    // Using the token and lifting the pause happen together or not at all.
89    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
116// ---- /monitoring/start/{token} ---------------------------------------------------------------
117
118/// What the start page shows.
119pub enum StartView {
120    /// The button. Names the site and address the link was made for.
121    Confirm {
122        domain: String,
123        email: String,
124    },
125    /// Unknown, expired or already used. A signed-in visitor gets the settings link instead of
126    /// the sign-in button.
127    Expired {
128        signed_in: bool,
129    },
130    Done(Box<StartDone>),
131}
132
133/// What confirming did.
134pub struct StartDone {
135    pub domain: String,
136    pub email: String,
137    pub site: SiteNote,
138    /// The site's audit screen, when the account has the site.
139    pub audit_href: Option<String>,
140    /// The key just minted; `None` when the account is at its key limit.
141    pub key: Option<MintedKey>,
142    pub key_limit: i64,
143    pub mcp_url: String,
144}
145
146pub enum SiteNote {
147    /// New site, first crawl queued.
148    Added,
149    /// The account already monitored this domain; nothing was queued.
150    Existing,
151    /// The plan has no room: the site was not added.
152    PlanFull { max_sites: u32 },
153}
154
155/// The key as the result page shows it, once.
156pub struct MintedKey {
157    pub key: String,
158    /// The Claude Code command with the key filled in.
159    pub command: String,
160    /// A `mcpServers` entry for MCP clients that take JSON.
161    pub json: String,
162}
163
164#[derive(Template)]
165#[template(path = "monitoring/start.html")]
166struct StartPage {
167    view: StartView,
168    token: String,
169}
170
171/// The page behind the emailed link: a button while the token is good, a dead end otherwise.
172/// Reads only.
173async 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
211/// Confirms: uses the token (once), signs the person in, adds the site, mints a key and shows it.
212/// If anything fails after the token is used, the token is made usable again, so the link in
213/// their inbox still works; what had already been done (the account, the site) is reused by the
214/// retry.
215async 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    // Defensive: a link is only ever issued for a host without a trailing dot.
252    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    // Opening a link from one of our emails counts as activity for the inactivity check.
270    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                // Marks the first crawl, so the funnel counts it as an agent's.
287                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                // The same link used twice at once: the other request added it first.
297                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    // The session first and the key last: the key is the one thing that can't be shown again,
326    // so nothing that can fail comes after it but rendering the page.
327    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            // A key nobody saw is worthless: take it back, so the retry mints the one shown.
364            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    // The key is on this page, once: nothing may cache it, and the session cookie goes with it.
373    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}