Skip to main content

grabcli_core/
login.rs

1//! `grab auth login`: a real phone login on weblogin.grab.com, driven over
2//! CDP inside an **isolated browser context**.
3//!
4//! grabcli does not replay or forge the login page's anti-bot tokens
5//! (reCAPTCHA Enterprise, the `x-hydraweb-jwt` fingerprint). Instead it opens
6//! `food.grab.com/auth/login` in a fresh `Target.createBrowserContext`
7//! context (own cookie jar, shared with no other tab, so nothing else in the
8//! browser can log it out) and lets the real page do its own checks, the way
9//! a person would. grabcli only:
10//!
11//! 1. types the phone number and keeps "Remember me … 60 days" ticked;
12//! 2. clicks Continue and, if Grab offers a choice, picks ONE challenge
13//!    (in-app approval or OTP) — this is the only step that sends anything;
14//! 3. later (same or another process) types the OTP, or waits for the
15//!    in-app approval;
16//! 4. lets the page redirect through `food.grab.com/auth/callback` inside the
17//!    context, reads the context's cookies with `Storage.getCookies`, saves
18//!    them as the session and disposes the context.
19//!
20//! The context outlives the process (`disposeOnDetach: false`); what's needed
21//! to resume (context/tab ids, expiry, the masked phone) is the
22//! [`PendingLogin`] value, which the caller keeps (the CLI in a 0600 file, a
23//! daemon in memory). The step functions take it as `&mut Option<_>` and set
24//! it to `None` once the login is over (logged in, or ended for good). Sends
25//! are rate-limited by an injectable [`SendBudget`] and never retried.
26//!
27//! This module writes no files and logs nothing: it never prints cookie
28//! values, tokens, OTPs or the full phone number. [`LoginDriver`] wraps the
29//! steps as a small state machine for daemons (one driver per login, driven
30//! from a thread via `block_on` or from a task).
31
32use std::sync::Mutex;
33use std::time::Duration;
34
35use async_trait::async_trait;
36use serde::{Deserialize, Serialize};
37use serde_json::{Value, json};
38
39use crate::cdp::{self, Browser, PageInfo};
40use crate::error::{Error, ErrorKind, Result};
41use crate::jwt;
42use crate::session::{LOCATION_COOKIE, Session};
43use crate::sso;
44
45/// Where the browser login starts (redirects to weblogin.grab.com).
46pub const LOGIN_START_URL: &str = sso::FOOD_LOGIN;
47/// How long a started login stays resumable. Grab's codes live ~5 min.
48pub const PENDING_TTL_SECS: i64 = 10 * 60;
49/// Minimum gap between two challenge sends.
50pub const SEND_COOLDOWN_SECS: i64 = 60;
51/// Maximum challenge sends in any rolling hour.
52pub const MAX_SENDS_PER_HOUR: usize = 3;
53
54// ------------------------------------------------------------------- phone
55
56/// Calling codes of the markets Grab serves, longest first.
57const CALLING_CODES: &[&str] = &["855", "60", "62", "63", "65", "66", "84", "95"];
58
59/// Split `+<cc><national>` into (`cc`, national digits). Spaces, dashes,
60/// dots and parentheses are ignored; a trunk `0` is dropped.
61pub fn split_phone(phone: &str) -> Result<(String, String)> {
62    let cleaned: String = phone
63        .chars()
64        .filter(|c| !matches!(c, ' ' | '-' | '.' | '(' | ')'))
65        .collect();
66    let digits = cleaned.strip_prefix('+').ok_or_else(|| {
67        Error::invalid("phone must be in international form, e.g. +63 9XX XXX XXXX")
68    })?;
69    if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) {
70        return Err(Error::invalid("phone must contain only digits after the +"));
71    }
72    let cc = CALLING_CODES
73        .iter()
74        .find(|cc| digits.starts_with(**cc))
75        .ok_or_else(|| Error::invalid("phone country code is not a Grab market"))?;
76    let national = digits[cc.len()..].trim_start_matches('0').to_string();
77    if !(6..=12).contains(&national.len()) {
78        return Err(Error::invalid("phone number has the wrong length"));
79    }
80    Ok(((*cc).to_string(), national))
81}
82
83/// `+63••••••••12`: the country code and the last two digits only.
84pub fn mask_phone(cc: &str, national: &str) -> String {
85    let n = national.len();
86    let keep = n.min(2);
87    format!("+{cc}{}{}", "•".repeat(n - keep), &national[n - keep..])
88}
89
90// ------------------------------------------------------------------ channel
91
92/// Which challenge to ask Grab for.
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(rename_all = "snake_case")]
95pub enum Channel {
96    /// Approve in the Grab app (push).
97    App,
98    /// A one-time code by SMS.
99    Sms,
100    /// A one-time code by WhatsApp.
101    Whatsapp,
102}
103
104impl Channel {
105    pub fn parse(s: &str) -> Result<Self> {
106        match s.to_ascii_lowercase().as_str() {
107            "app" | "push" => Ok(Self::App),
108            "sms" => Ok(Self::Sms),
109            "whatsapp" | "wa" => Ok(Self::Whatsapp),
110            _ => Err(Error::invalid("--channel must be app, sms or whatsapp")),
111        }
112    }
113
114    pub fn as_str(self) -> &'static str {
115        match self {
116            Self::App => "app",
117            Self::Sms => "sms",
118            Self::Whatsapp => "whatsapp",
119        }
120    }
121
122    /// What one send produces, in words.
123    pub fn describe(self) -> &'static str {
124        match self {
125            Self::App => "one approval request in the Grab app",
126            Self::Sms | Self::Whatsapp => {
127                "one OTP code (Grab chooses SMS or WhatsApp delivery; the CLI never resends to switch)"
128            }
129        }
130    }
131
132    /// weblogin challenge types (the `Proceed to <TYPE>` options) that
133    /// satisfy this channel, best first.
134    pub fn challenge_types(self) -> &'static [&'static str] {
135        match self {
136            Self::App => &["PUSH_V2", "PUSH"],
137            Self::Sms | Self::Whatsapp => &["CHANNEL_OPTIMIZED_PHONE_OTP", "SMS_OTP"],
138        }
139    }
140}
141
142// ------------------------------------------------------------ pending state
143
144/// What the send produced.
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
146#[serde(rename_all = "snake_case")]
147pub enum PendingKind {
148    /// A code was sent (SMS/WhatsApp); finish with `--otp`.
149    OtpSent,
150    /// An in-app approval was requested; finish with `--poll`.
151    ApprovalPending,
152}
153
154impl PendingKind {
155    pub fn as_str(self) -> &'static str {
156        match self {
157            Self::OtpSent => "otp_sent",
158            Self::ApprovalPending => "approval_pending",
159        }
160    }
161}
162
163/// State kept between `--phone …` and `--otp` / `--poll`. No secrets:
164/// ids of the isolated context/tab in the browser and a masked phone.
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct PendingLogin {
167    pub kind: PendingKind,
168    pub cdp_base: String,
169    pub browser_context_id: String,
170    pub target_id: String,
171    pub created_at: i64,
172    pub expires_at: i64,
173    pub phone_masked: String,
174    pub channel_requested: Channel,
175    /// Delivery Grab reported on the page (`SMS`, `WhatsApp`, `app`), if known.
176    #[serde(default)]
177    pub channel_sent: Option<String>,
178    /// Whether to copy the saved address (`location` cookie) from the
179    /// browser's default context when the session has none. The CLI does
180    /// (it's the user's own browser); a shared daemon browser must not.
181    #[serde(default = "yes")]
182    pub copy_browser_address: bool,
183}
184
185fn yes() -> bool {
186    true
187}
188
189impl PendingLogin {
190    pub fn is_expired(&self, now: i64) -> bool {
191        self.expires_at <= now
192    }
193
194    /// The `--json` document for a pending login: `otp_sent` or
195    /// `approval_pending`, with the command that finishes it.
196    pub fn next_command(&self) -> &'static str {
197        match self.kind {
198            PendingKind::OtpSent => "grab auth login --otp <code>",
199            PendingKind::ApprovalPending => "grab auth login --poll",
200        }
201    }
202
203    pub fn summary(&self, now: i64) -> Value {
204        json!({
205            "kind": self.kind.as_str(),
206            "phone": self.phone_masked,
207            "channel_requested": self.channel_requested.as_str(),
208            "channel_sent": self.channel_sent,
209            "expires_in_secs": (self.expires_at - now).max(0),
210            "expired": self.is_expired(now),
211        })
212    }
213}
214
215/// stdout document after `--phone` (or a `--poll` that is still waiting).
216pub fn pending_json(p: &PendingLogin, now: i64) -> Value {
217    let mut v = p.summary(now);
218    v["expires_at"] = json!(p.expires_at);
219    v["next"] = json!(p.next_command());
220    v
221}
222
223// ---------------------------------------------------------------- send log
224
225/// Timestamps of challenge sends, kept separately from the pending login so
226/// cancelling a login never resets the limits. A plain value: persisting it
227/// is up to the [`SendBudget`] that holds it.
228#[derive(Debug, Clone, Default, Serialize, Deserialize)]
229pub struct SendLog {
230    pub sends: Vec<i64>,
231}
232
233impl SendLog {
234    fn recent(&self, now: i64) -> impl Iterator<Item = i64> + '_ {
235        self.sends.iter().copied().filter(move |t| now - t < 3600)
236    }
237
238    pub fn sends_last_hour(&self, now: i64) -> usize {
239        self.recent(now).count()
240    }
241
242    /// Seconds until another send is allowed (0 = now).
243    pub fn wait_secs(&self, now: i64) -> i64 {
244        let mut recent: Vec<i64> = self.recent(now).collect();
245        recent.sort_unstable();
246        let cooldown = recent
247            .last()
248            .map_or(0, |last| last + SEND_COOLDOWN_SECS - now);
249        let hourly = if recent.len() >= MAX_SENDS_PER_HOUR {
250            recent[recent.len() - MAX_SENDS_PER_HOUR] + 3600 - now
251        } else {
252            0
253        };
254        cooldown.max(hourly).max(0)
255    }
256
257    /// Fail with `rate_limited` if a send now would break the cooldown or
258    /// the hourly cap.
259    pub fn check(&self, now: i64) -> Result<()> {
260        let wait = self.wait_secs(now);
261        if wait == 0 {
262            return Ok(());
263        }
264        let why = if self.sends_last_hour(now) >= MAX_SENDS_PER_HOUR {
265            format!("{MAX_SENDS_PER_HOUR} login challenges were already sent in the last hour")
266        } else {
267            format!("a login challenge was sent less than {SEND_COOLDOWN_SECS}s ago")
268        };
269        Err(Error::new(
270            ErrorKind::RateLimited,
271            format!("{why}; nothing was sent. Try again in {wait}s."),
272        )
273        .with_details(json!({"retry_after_secs": wait})))
274    }
275
276    /// Record a send (before it happens: a crash still counts) and drop
277    /// entries older than an hour.
278    pub fn record(&mut self, now: i64) {
279        self.sends.retain(|t| now - t < 3600);
280        self.sends.push(now);
281    }
282}
283
284/// The challenge-send budget ([`SEND_COOLDOWN_SECS`],
285/// [`MAX_SENDS_PER_HOUR`]). Injectable so the caller decides its scope and
286/// storage: the CLI keeps one per user in a file; a daemon keeps one per org.
287#[async_trait]
288pub trait SendBudget: Send + Sync {
289    /// Fail with `rate_limited` if a send now would break the limits.
290    async fn check(&self, now: i64) -> Result<()>;
291    /// Record a send. Called before the send happens (a crash still counts);
292    /// an error aborts the login before anything is sent.
293    async fn record(&self, now: i64) -> Result<()>;
294}
295
296/// A [`SendLog`] held in memory.
297#[derive(Debug, Default)]
298pub struct MemorySendBudget {
299    pub log: Mutex<SendLog>,
300}
301
302impl MemorySendBudget {
303    fn lock(&self) -> std::sync::MutexGuard<'_, SendLog> {
304        self.log
305            .lock()
306            .unwrap_or_else(std::sync::PoisonError::into_inner)
307    }
308
309    /// A copy of the log.
310    pub fn snapshot(&self) -> SendLog {
311        self.lock().clone()
312    }
313}
314
315#[async_trait]
316impl SendBudget for MemorySendBudget {
317    async fn check(&self, now: i64) -> Result<()> {
318        self.lock().check(now)
319    }
320
321    async fn record(&self, now: i64) -> Result<()> {
322        self.lock().record(now);
323        Ok(())
324    }
325}
326
327// ------------------------------------------------------- shared-tab warning
328
329/// food.grab.com tabs in the shared browser that poll order tracking. When
330/// the browser's food session lapses, such a tab gets 401s from `/track` and
331/// the page then calls GrabID `oauth2/logout` + food `/auth/logout` (every
332/// ~16 s), which logs the browser out again right after any login there.
333/// The isolated login context is immune, but these tabs still break
334/// `grab auth import` and normal browsing. grabcli never closes them.
335pub fn order_tracking_tabs(pages: &[PageInfo]) -> Vec<String> {
336    pages
337        .iter()
338        .filter_map(|p| {
339            let u = reqwest::Url::parse(&p.url).ok()?;
340            let path = u.path();
341            (u.host_str() == Some("food.grab.com")
342                && (path.contains("/order/") || path.contains("/track")))
343            .then(|| format!("food.grab.com{path}"))
344        })
345        .collect()
346}
347
348pub fn tracking_tab_warning(tabs: &[String]) -> Option<String> {
349    if tabs.is_empty() {
350        return None;
351    }
352    Some(format!(
353        "{} food.grab.com order-tracking tab(s) may be open in the browser ({}). When the \
354         browser's food session lapses such tabs poll /track, get 401 and call Grab's logout \
355         every ~16s, which logs that browser out. If they are open, close them (grabcli won't \
356         touch other tabs).",
357        tabs.len(),
358        tabs.join(", ")
359    ))
360}
361
362// ------------------------------------------------------------- page model
363
364/// Where the weblogin page is, from [`PROBE_JS`].
365#[derive(Debug, Clone, PartialEq, Eq)]
366pub enum Screen {
367    /// Still loading / spinner.
368    Loading,
369    /// The phone-number form.
370    Phone,
371    /// "Choose a preferred way to sign in" with these challenge types.
372    Options(Vec<String>),
373    /// Waiting for a code; `channel` as shown ("SMS", "WhatsApp", …).
374    Otp {
375        channel: Option<String>,
376    },
377    /// The code was not accepted.
378    OtpRejected,
379    /// Waiting for an in-app approval.
380    Approval,
381    /// Back on food.grab.com: logged in.
382    Done,
383    /// A step-up that needs a human (reCAPTCHA v2, selfie, PIN, …).
384    StepUp(String),
385    /// An error banner (blocked, rate limited, expired, rejected, …).
386    Problem(String),
387    Unknown(String),
388}
389
390/// Read-only probe of the login tab. Returns structure and fixed UI strings
391/// only; the CLI never prints `text`.
392pub const PROBE_JS: &str = r#"(() => {
393  const root = document.getElementById('root');
394  const options = [...document.querySelectorAll('[role=button] img[alt^="Proceed to "]')]
395    .map(i => i.getAttribute('alt').slice('Proceed to '.length));
396  const captcha = [...document.querySelectorAll('iframe')].some(f =>
397    /recaptcha\/(api2|enterprise)\/bframe/.test(f.src || '') && f.getBoundingClientRect().height > 100);
398  return {
399    host: location.host, path: location.pathname, ready: document.readyState,
400    has_phone: !!document.getElementById('Phone'), options, captcha,
401    text: ((root && root.innerText) || (document.body && document.body.innerText) || '').slice(0, 3000),
402  };
403})()"#;
404
405const PROBLEMS: &[&str] = &[
406    "Account temporarily blocked",
407    "temporarily locked",
408    "You've tried too many times",
409    "Try again later",
410    "Too many attempts",
411    "Unable to resend code",
412    "OTP challenge expired",
413    "Request rejected",
414    "QR Code Expired",
415    "Account Not Found",
416    "Oops, something went wrong",
417];
418
419pub fn classify_screen(p: &Value) -> Screen {
420    let s = |k: &str| p.get(k).and_then(Value::as_str).unwrap_or_default();
421    let host = s("host");
422    let path = s("path");
423    let text = s("text");
424    if host == "food.grab.com" && !path.starts_with("/auth/") {
425        return Screen::Done;
426    }
427    if host != "weblogin.grab.com" {
428        return if host.is_empty() || host == "food.grab.com" {
429            Screen::Loading
430        } else {
431            Screen::Unknown(host.to_string())
432        };
433    }
434    if p.get("captcha").and_then(Value::as_bool) == Some(true) {
435        return Screen::StepUp("recaptcha".into());
436    }
437    if let Some(pr) = PROBLEMS.iter().find(|w| text.contains(**w)) {
438        return Screen::Problem((*pr).to_string());
439    }
440    if text.contains("Incorrect code") || text.contains("no longer valid") {
441        return Screen::OtpRejected;
442    }
443    let segs: Vec<&str> = path.split('/').filter(|x| !x.is_empty()).collect();
444    let has = |x: &str| segs.contains(&x);
445    let otp_channel = || {
446        ["SMS", "WhatsApp", "Viber", "Zalo"]
447            .iter()
448            .find(|c| text.contains(&format!("Check your {c}")))
449            .map(|c| (*c).to_string())
450    };
451    if has("optimised-sms-otp") || has("sms-otp") || has("email-otp") {
452        return Screen::Otp {
453            channel: otp_channel(),
454        };
455    }
456    if has("push-v2") || has("push") {
457        return Screen::Approval;
458    }
459    if let Some(seg) = [
460        "recaptcha",
461        "arkose",
462        "web-selfie",
463        "passkey",
464        "password",
465        "pin",
466    ]
467    .iter()
468    .find(|x| has(x))
469    {
470        return Screen::StepUp((*seg).to_string());
471    }
472    let options: Vec<String> = p
473        .get("options")
474        .and_then(Value::as_array)
475        .map(|a| {
476            a.iter()
477                .filter_map(Value::as_str)
478                .map(String::from)
479                .collect()
480        })
481        .unwrap_or_default();
482    if has("challenge-options") || (!options.is_empty() && text.contains("Choose")) {
483        return Screen::Options(options);
484    }
485    // Fallbacks on the UI copy in case the route names change.
486    if text.contains("Approve in mobile app") || text.contains("App push notification sent") {
487        return Screen::Approval;
488    }
489    if text.contains("Check your") || text.contains("Enter verification code") {
490        return Screen::Otp {
491            channel: otp_channel(),
492        };
493    }
494    if p.get("has_phone").and_then(Value::as_bool) == Some(true) {
495        return Screen::Phone;
496    }
497    Screen::Loading
498}
499
500/// Pick the challenge option to click for `channel`, if Grab offers one.
501pub fn pick_option(channel: Channel, offered: &[String]) -> Option<&'static str> {
502    channel
503        .challenge_types()
504        .iter()
505        .copied()
506        .find(|t| offered.iter().any(|o| o == t))
507}
508
509// ------------------------------------------------------------- dry run
510
511/// What `--phone …` would do, without doing it (`--dry-run`).
512pub fn dry_run_plan(
513    phone_masked: &str,
514    channel: Channel,
515    cdp_base: &str,
516    log: &SendLog,
517    now: i64,
518) -> Value {
519    json!({
520        "kind": "dry_run",
521        "dry_run": true,
522        "sent": false,
523        "phone": phone_masked,
524        "channel": channel.as_str(),
525        "cdp": cdp_base,
526        "would_send": channel.describe(),
527        "steps": [
528            "try a silent SSO refresh first (never sends anything); stop if it works",
529            "open food.grab.com/auth/login in a NEW isolated browser context (no shared cookies, other tabs untouched)",
530            "type the phone number, keep \"Remember me on this device for 60 days\" ticked, click Continue",
531            format!("if Grab offers a choice, pick {} (once; never resend or retry)", channel.as_str()),
532            format!("stop and keep the login tab alive for {} min; finish with `grab auth login --otp <code>` or `grab auth login --poll`", PENDING_TTL_SECS / 60),
533        ],
534        "sends_last_hour": log.sends_last_hour(now),
535        "max_sends_per_hour": MAX_SENDS_PER_HOUR,
536        "next_send_allowed_in_secs": log.wait_secs(now),
537    })
538}
539
540// ------------------------------------------------------------ the driver
541
542/// JS: centre of the first element matching `selector` (scrolled into
543/// view), or null.
544fn rect_js(find_expr: &str) -> String {
545    format!(
546        r#"(() => {{ const el = {find_expr}; if (!el) return null;
547  el.scrollIntoView({{block: 'center'}}); const r = el.getBoundingClientRect();
548  if (r.width === 0 || r.height === 0) return null;
549  return {{x: r.left + r.width / 2, y: r.top + r.height / 2, disabled: !!el.disabled}}; }})()"#
550    )
551}
552
553const CONTINUE_BTN: &str = "[...document.querySelectorAll('button.dui-btn-primary, button')].find(b => b.innerText.trim() === 'Continue')";
554const PHONE_INPUT: &str = "document.getElementById('Phone')";
555const REMEMBER_LABEL: &str =
556    "[...document.querySelectorAll('label')].find(l => /Remember me/.test(l.innerText))";
557const REMEMBER_STATE: &str = r#"(() => { const l = [...document.querySelectorAll('label')].find(l => /Remember me/.test(l.innerText));
558  if (!l) return 'absent'; const i = l.querySelector('input[type=checkbox]');
559  return (i && i.checked) || /checkbox-wrapper-checked/.test(l.className) ? 'checked' : 'unchecked'; })()"#;
560/// The OTP input: the first visible, empty-ish text-like input on the page.
561const OTP_INPUT: &str = "[...document.querySelectorAll('#root input')].filter(i => i.id !== 'Phone' && !['checkbox','search','hidden'].includes(i.type) && i.offsetParent !== null).find(i => !i.value) || [...document.querySelectorAll('#root input')].find(i => i.id !== 'Phone' && !['checkbox','search','hidden'].includes(i.type) && i.offsetParent !== null)";
562
563struct Tab {
564    browser: Browser,
565    session: String,
566}
567
568impl Tab {
569    async fn probe(&mut self) -> Result<Screen> {
570        // Keep the CDP event buffer from growing while we poll.
571        self.browser.drain_events(Duration::ZERO).await.ok();
572        match self.browser.eval(&self.session, PROBE_JS).await {
573            Ok(v) => Ok(classify_screen(&v)),
574            // Mid-navigation the execution context can vanish; treat as loading.
575            Err(_) => Ok(Screen::Loading),
576        }
577    }
578
579    /// Poll until `done(screen)` or `timeout`; returns the last screen.
580    async fn wait_for(
581        &mut self,
582        timeout: Duration,
583        done: impl Fn(&Screen) -> bool,
584    ) -> Result<Screen> {
585        let deadline = tokio::time::Instant::now() + timeout;
586        loop {
587            let s = self.probe().await?;
588            if done(&s) || tokio::time::Instant::now() >= deadline {
589                return Ok(s);
590            }
591            tokio::time::sleep(Duration::from_millis(700)).await;
592        }
593    }
594
595    async fn rect(&mut self, find_expr: &str) -> Result<Option<(f64, f64, bool)>> {
596        let v = self
597            .browser
598            .eval(&self.session, &rect_js(find_expr))
599            .await?;
600        Ok(
601            match (
602                v.get("x").and_then(Value::as_f64),
603                v.get("y").and_then(Value::as_f64),
604            ) {
605                (Some(x), Some(y)) => Some((
606                    x,
607                    y,
608                    v.get("disabled").and_then(Value::as_bool).unwrap_or(false),
609                )),
610                _ => None,
611            },
612        )
613    }
614
615    async fn click(&mut self, find_expr: &str, what: &str) -> Result<()> {
616        let (x, y, _) = self
617            .rect(find_expr)
618            .await?
619            .ok_or_else(|| Error::new(ErrorKind::Api, format!("login page: no {what} found")))?;
620        self.browser.click_at(&self.session, x, y).await
621    }
622}
623
624/// Options for [`start`].
625#[derive(Clone, Copy)]
626pub struct StartOpts<'a> {
627    pub cdp_base: &'a str,
628    pub phone: &'a str,
629    pub channel: Channel,
630    /// See [`PendingLogin::copy_browser_address`].
631    pub copy_browser_address: bool,
632}
633
634fn page_error(s: &Screen) -> Error {
635    match s {
636        Screen::StepUp(kind) => Error::new(
637            ErrorKind::CaptchaRequired,
638            format!(
639                "Grab asked for an extra check ({kind}) that needs a person. Log in at food.grab.com \
640                 in a normal browser window instead, then run `grab auth import`"
641            ),
642        ),
643        Screen::Problem(p) if p.contains("block") || p.contains("many") || p.contains("later") => {
644            Error::new(
645                ErrorKind::RateLimited,
646                format!("Grab says: {p}. Wait before trying again."),
647            )
648        }
649        Screen::Problem(p) => Error::new(ErrorKind::LoginExpired, format!("Grab says: {p}")),
650        other => Error::new(
651            ErrorKind::Api,
652            format!(
653                "the login page is in an unexpected state ({})",
654                screen_name(other)
655            ),
656        ),
657    }
658}
659
660fn screen_name(s: &Screen) -> &'static str {
661    match s {
662        Screen::Loading => "loading",
663        Screen::Phone => "phone form",
664        Screen::Options(_) => "challenge options",
665        Screen::Otp { .. } => "code entry",
666        Screen::OtpRejected => "code rejected",
667        Screen::Approval => "in-app approval",
668        Screen::Done => "logged in",
669        Screen::StepUp(_) => "step-up",
670        Screen::Problem(_) => "error banner",
671        Screen::Unknown(_) => "unknown",
672    }
673}
674
675/// Best-effort: dispose the isolated context (and its tab and cookies).
676async fn dispose(cdp_base: &str, context: &str) {
677    if let Ok(mut b) = Browser::connect(cdp_base).await {
678        b.dispose_context(context).await.ok();
679    }
680}
681
682/// Start a login and send exactly ONE challenge. The caller has already
683/// decided a send is wanted, that silent refresh failed, and that no other
684/// login is pending (see [`check_no_pending`]). Returns the pending login to
685/// keep for [`finish_with_otp`] / [`wait_for_approval`].
686pub async fn start(opts: StartOpts<'_>, budget: &dyn SendBudget) -> Result<PendingLogin> {
687    let now = jwt::now();
688    let (cc, national) = split_phone(opts.phone)?;
689    let masked = mask_phone(&cc, &national);
690    budget.check(now).await?;
691
692    let mut browser = Browser::connect(opts.cdp_base).await?;
693    let ctx = browser.create_context().await?;
694    let result = drive_send(browser, &ctx, opts, &cc, &national, &masked, budget).await;
695    match result {
696        Ok(p) => Ok(p),
697        Err(e) => {
698            dispose(opts.cdp_base, &ctx).await;
699            Err(e)
700        }
701    }
702}
703
704async fn drive_send(
705    mut browser: Browser,
706    ctx: &str,
707    opts: StartOpts<'_>,
708    cc: &str,
709    national: &str,
710    masked: &str,
711    budget: &dyn SendBudget,
712) -> Result<PendingLogin> {
713    let target = browser.open_tab(ctx, "about:blank").await?;
714    let session = browser.attach(&target).await?;
715    browser.navigate(&session, LOGIN_START_URL).await?;
716    let mut tab = Tab { browser, session };
717
718    let s = tab
719        .wait_for(Duration::from_secs(45), |s| !matches!(s, Screen::Loading))
720        .await?;
721    if s != Screen::Phone {
722        return Err(page_error(&s));
723    }
724    // Let reCAPTCHA / the fingerprint script settle like a person would.
725    tokio::time::sleep(Duration::from_secs(2)).await;
726
727    let shown = tab.probe_text().await?;
728    if !shown.contains(&format!("+{cc}")) {
729        return Err(Error::invalid(format!(
730            "the login page isn't set to +{cc}; only the page's default country is supported. Nothing was sent."
731        )));
732    }
733    if tab
734        .browser
735        .eval(&tab.session, REMEMBER_STATE)
736        .await?
737        .as_str()
738        == Some("unchecked")
739    {
740        tab.click(REMEMBER_LABEL, "remember-me checkbox").await?;
741    }
742    tab.click(PHONE_INPUT, "phone field").await?;
743    tab.browser.type_digits(&tab.session, national).await?;
744    let typed = tab
745        .browser
746        .eval(
747            &tab.session,
748            "(document.getElementById('Phone') || {}).value.replace(/\\D/g, '')",
749        )
750        .await?;
751    if typed.as_str() != Some(national) {
752        return Err(Error::new(
753            ErrorKind::Api,
754            "the phone field didn't take the number as typed; nothing was sent",
755        ));
756    }
757    tokio::time::sleep(Duration::from_millis(600)).await;
758    match tab.rect(CONTINUE_BTN).await? {
759        Some((_, _, false)) => {}
760        _ => {
761            return Err(Error::new(
762                ErrorKind::Api,
763                "the Continue button is missing or disabled; nothing was sent",
764            ));
765        }
766    }
767
768    // ---- Point of no return: from here on a challenge may be sent. Record
769    // the send first so a crash still counts against the limits.
770    budget.record(jwt::now()).await?;
771    tab.click(CONTINUE_BTN, "Continue button").await?;
772    let mut s = tab
773        .wait_for(Duration::from_secs(40), |s| {
774            !matches!(s, Screen::Phone | Screen::Loading)
775        })
776        .await?;
777    if let Screen::Options(offered) = &s {
778        let Some(kind) = pick_option(opts.channel, offered) else {
779            return Err(Error::invalid(format!(
780                "Grab didn't offer {} for this account (offered: {}). Nothing was sent; the attempt still counts toward the limit.",
781                opts.channel.as_str(),
782                offered.join(", ")
783            )));
784        };
785        let opt = format!(
786            "(() => {{ const i = document.querySelector('[role=button] img[alt=\"Proceed to {kind}\"]'); return i ? i.closest('[role=button]') : null; }})()"
787        );
788        tab.click(&opt, "challenge option").await?;
789        s = tab
790            .wait_for(Duration::from_secs(40), |s| {
791                !matches!(s, Screen::Options(_) | Screen::Loading)
792            })
793            .await?;
794    }
795    let (kind, channel_sent) = match &s {
796        Screen::Otp { channel } => (PendingKind::OtpSent, channel.clone()),
797        Screen::Approval => (PendingKind::ApprovalPending, Some("app".to_string())),
798        Screen::Phone => {
799            return Err(Error::new(
800                ErrorKind::Api,
801                "the login page stayed on the phone form after Continue (maybe a field error). Not retrying.",
802            ));
803        }
804        other => return Err(page_error(other)),
805    };
806    let now = jwt::now();
807    let p = PendingLogin {
808        kind,
809        cdp_base: opts.cdp_base.to_string(),
810        browser_context_id: ctx.to_string(),
811        target_id: target,
812        created_at: now,
813        expires_at: now + PENDING_TTL_SECS,
814        phone_masked: masked.to_string(),
815        channel_requested: opts.channel,
816        channel_sent,
817        copy_browser_address: opts.copy_browser_address,
818    };
819    Ok(p)
820}
821
822impl Tab {
823    async fn probe_text(&mut self) -> Result<String> {
824        let v = self.browser.eval(&self.session, PROBE_JS).await?;
825        Ok(v.get("text")
826            .and_then(Value::as_str)
827            .unwrap_or_default()
828            .to_string())
829    }
830}
831
832/// Before [`start`]: refuse while an unexpired login is pending; dispose an
833/// expired one (and clear `slot`).
834pub async fn check_no_pending(slot: &mut Option<PendingLogin>, now: i64) -> Result<()> {
835    if let Some(p) = slot.as_ref() {
836        if !p.is_expired(now) {
837            return Err(Error::invalid(format!(
838                "a login for {} is already pending ({}, {}s left): finish it with `grab auth login --otp <code>` / `--poll`, or run `grab auth login --cancel`",
839                p.phone_masked,
840                p.kind.as_str(),
841                p.expires_at - now
842            )));
843        }
844        dispose(&p.cdp_base, &p.browser_context_id).await;
845        *slot = None;
846    }
847    Ok(())
848}
849
850/// Reattach to the pending login's tab, or clean up (clearing `slot`) and
851/// explain.
852async fn reattach(slot: &mut Option<PendingLogin>) -> Result<(Tab, PendingLogin)> {
853    let p = slot.clone().ok_or_else(no_pending)?;
854    if p.is_expired(jwt::now()) {
855        dispose(&p.cdp_base, &p.browser_context_id).await;
856        *slot = None;
857        return Err(Error::new(
858            ErrorKind::LoginExpired,
859            "the pending login expired; start again with `grab auth login --phone <number>` (mind the send limits)",
860        ));
861    }
862    let mut browser = Browser::connect(&p.cdp_base).await?;
863    if !browser.context_exists(&p.browser_context_id).await?
864        || !browser
865            .tab_in_context(&p.browser_context_id, &p.target_id)
866            .await?
867    {
868        *slot = None;
869        return Err(Error::new(
870            ErrorKind::LoginExpired,
871            "the login tab is gone (browser restarted or tab closed); start again with `grab auth login --phone <number>`",
872        ));
873    }
874    let session = browser.attach(&p.target_id).await?;
875    Ok((Tab { browser, session }, p))
876}
877
878/// Poll the context's cookies for a logged-in `gfc_session`; return them as
879/// the session (on top of `prev`), dispose the context and clear `slot`.
880async fn collect_session(
881    tab: &mut Tab,
882    p: &PendingLogin,
883    slot: &mut Option<PendingLogin>,
884    prev: &Session,
885) -> Result<Session> {
886    let deadline = tokio::time::Instant::now() + Duration::from_secs(45);
887    loop {
888        let cookies = tab.browser.context_cookies(&p.browser_context_id).await?;
889        let mut s = prev.clone();
890        s.cookies = cookies;
891        if s.gfc_logged_in() {
892            // The isolated context has no saved address/country/language:
893            // keep the previous session's, else (if allowed) take the address
894            // from the browser's normal (default) context. Values stay in
895            // memory.
896            s.carry_over_preferences(prev);
897            if p.copy_browser_address
898                && s.cookie(LOCATION_COOKIE).is_none()
899                && let Ok(def) = tab.browser.default_cookies().await
900            {
901                s.replace_named_cookies(LOCATION_COOKIE, &def);
902            }
903            if !tab.browser.user_agent.is_empty() {
904                s.user_agent = Some(tab.browser.user_agent.clone());
905            }
906            s.exported_at = Some(jwt::now() as f64);
907            tab.browser
908                .dispose_context(&p.browser_context_id)
909                .await
910                .ok();
911            *slot = None;
912            return Ok(s);
913        }
914        if tokio::time::Instant::now() >= deadline {
915            return Err(Error::new(
916                ErrorKind::Api,
917                "the login page finished but food.grab.com set no logged-in gfc_session; the tab is kept until the pending login expires",
918            ));
919        }
920        tokio::time::sleep(Duration::from_secs(1)).await;
921    }
922}
923
924/// `grab auth login --otp <code>`: type the code into the waiting page.
925/// `prev` is the session to build on (its preference cookies are kept);
926/// pass `&Session::default()` for none. On success (and on a terminal
927/// failure) `slot` is cleared; a rejected code leaves it for another try.
928pub async fn finish_with_otp(
929    slot: &mut Option<PendingLogin>,
930    otp: &str,
931    prev: &Session,
932) -> Result<Session> {
933    let otp = otp.trim();
934    if !(4..=8).contains(&otp.len()) || !otp.chars().all(|c| c.is_ascii_digit()) {
935        return Err(Error::invalid("the code should be 4-8 digits"));
936    }
937    let kind = slot.as_ref().ok_or_else(no_pending)?.kind;
938    if kind != PendingKind::OtpSent {
939        return Err(Error::invalid(
940            "this login is waiting for an in-app approval: run `grab auth login --poll`",
941        ));
942    }
943    let (mut tab, p) = reattach(slot).await?;
944    match tab.probe().await? {
945        Screen::Otp { .. } | Screen::OtpRejected => {}
946        Screen::Done => return collect_session(&mut tab, &p, slot, prev).await,
947        other => return Err(finish_error(&p, slot, &other).await),
948    }
949    tab.click(OTP_INPUT, "code field").await?;
950    tab.browser.type_digits(&tab.session, otp).await?;
951    // The page submits by itself once the code is complete.
952    let s = tab
953        .wait_for(Duration::from_secs(45), |s| {
954            !matches!(s, Screen::Otp { .. } | Screen::Loading)
955        })
956        .await?;
957    match s {
958        Screen::Done => collect_session(&mut tab, &p, slot, prev).await,
959        Screen::OtpRejected => Err(Error::new(
960            ErrorKind::OtpInvalid,
961            "Grab didn't accept the code; check it and run `grab auth login --otp <code>` again (the page allows only a few tries)",
962        )),
963        Screen::Otp { .. } | Screen::Loading => {
964            // Maybe the redirect is just slow: check the cookies anyway.
965            collect_session(&mut tab, &p, slot, prev).await
966        }
967        other => Err(finish_error(&p, slot, &other).await),
968    }
969}
970
971/// Outcome of [`wait_for_approval`].
972#[derive(Debug)]
973pub enum WaitOutcome {
974    LoggedIn(Session),
975    /// Still waiting; the pending login is unchanged in the slot.
976    StillWaiting,
977}
978
979/// `grab auth login --poll`: wait up to `timeout` for the in-app approval.
980/// Same `slot` / `prev` rules as [`finish_with_otp`].
981pub async fn wait_for_approval(
982    slot: &mut Option<PendingLogin>,
983    timeout: Duration,
984    prev: &Session,
985) -> Result<WaitOutcome> {
986    let kind = slot.as_ref().ok_or_else(no_pending)?.kind;
987    if kind != PendingKind::ApprovalPending {
988        return Err(Error::invalid(
989            "this login is waiting for a code: run `grab auth login --otp <code>`",
990        ));
991    }
992    let (mut tab, p) = reattach(slot).await?;
993    let left = u64::try_from(p.expires_at - jwt::now()).unwrap_or(0);
994    let timeout = timeout.min(Duration::from_secs(left));
995    let s = tab
996        .wait_for(timeout, |s| {
997            !matches!(s, Screen::Approval | Screen::Loading)
998        })
999        .await?;
1000    match s {
1001        Screen::Done => Ok(WaitOutcome::LoggedIn(
1002            collect_session(&mut tab, &p, slot, prev).await?,
1003        )),
1004        Screen::Approval | Screen::Loading => Ok(WaitOutcome::StillWaiting),
1005        other => Err(finish_error(&p, slot, &other).await),
1006    }
1007}
1008
1009/// A terminal page state while finishing: clean up and map the error.
1010async fn finish_error(p: &PendingLogin, slot: &mut Option<PendingLogin>, s: &Screen) -> Error {
1011    dispose(&p.cdp_base, &p.browser_context_id).await;
1012    *slot = None;
1013    page_error(s)
1014}
1015
1016fn no_pending() -> Error {
1017    Error::new(
1018        ErrorKind::LoginExpired,
1019        "no login in progress: start one with `grab auth login --phone <number>`",
1020    )
1021}
1022
1023/// `grab auth login --cancel`: dispose the isolated context, forget the
1024/// pending login (clearing `slot`). Send limits are kept. Returns whether
1025/// there was one.
1026pub async fn cancel(slot: &mut Option<PendingLogin>) -> bool {
1027    let Some(p) = slot.take() else {
1028        return false;
1029    };
1030    dispose(&p.cdp_base, &p.browser_context_id).await;
1031    true
1032}
1033
1034/// Order-tracking tabs in the shared browser (best-effort; empty if the
1035/// browser is unreachable). `/json` can still list a tab for a moment after
1036/// it was closed, so a non-empty answer is re-checked once after a short
1037/// delay and only tabs seen both times are reported.
1038pub async fn shared_browser_tabs(cdp_base: &str) -> Vec<String> {
1039    let first = cdp::pages(cdp_base)
1040        .await
1041        .map(|p| order_tracking_tabs(&p))
1042        .unwrap_or_default();
1043    if first.is_empty() {
1044        return first;
1045    }
1046    tokio::time::sleep(Duration::from_millis(1500)).await;
1047    let second = cdp::pages(cdp_base)
1048        .await
1049        .map(|p| order_tracking_tabs(&p))
1050        .unwrap_or_default();
1051    still_open(&first, &second)
1052}
1053
1054/// Tabs present in both listings.
1055pub fn still_open(first: &[String], second: &[String]) -> Vec<String> {
1056    first
1057        .iter()
1058        .filter(|t| second.contains(t))
1059        .cloned()
1060        .collect()
1061}
1062
1063// ------------------------------------------------------------ step driver
1064
1065/// What a [`LoginDriver`] is waiting for, or how it ended. Carries no phone
1066/// number or code (only the masked phone) and redacts the session in `Debug`.
1067#[derive(Debug, Clone)]
1068pub enum LoginState {
1069    /// A code was sent by SMS/WhatsApp: call [`LoginDriver::submit_code`].
1070    /// `error` is set after a rejected code (another try is allowed).
1071    AwaitingCode {
1072        phone_masked: String,
1073        /// Delivery Grab reported (`SMS`, `WhatsApp`), if known.
1074        channel: Option<String>,
1075        expires_at: i64,
1076        error: Option<String>,
1077    },
1078    /// An approval request went to the Grab app: call [`LoginDriver::poll`].
1079    AwaitingApproval {
1080        phone_masked: String,
1081        expires_at: i64,
1082    },
1083    /// Logged in. The session holds the captured cookies; nothing was saved
1084    /// anywhere: persist it through your own store.
1085    Done(Session),
1086    /// Ended for good (step-up, expiry, rate limit, page error, cancel).
1087    Failed(String),
1088}
1089
1090impl LoginState {
1091    pub fn is_terminal(&self) -> bool {
1092        matches!(self, Self::Done(_) | Self::Failed(_))
1093    }
1094}
1095
1096/// What to start a [`LoginDriver`] with.
1097#[derive(Clone)]
1098pub struct LoginRequest<'a> {
1099    /// CDP endpoint of a headed browser (e.g. `http://127.0.0.1:9222`).
1100    pub cdp_url: &'a str,
1101    /// International form (`+63…`). Used only to type into the page; never
1102    /// stored, logged or returned (only masked).
1103    pub phone: &'a str,
1104    pub channel: Channel,
1105    /// The previous session for this account, if any: its address/country/
1106    /// language cookies are carried over.
1107    pub previous: Option<Session>,
1108    /// Copy the address from the browser's default context when there is no
1109    /// previous one. Keep `false` for a browser shared between accounts.
1110    pub copy_browser_address: bool,
1111}
1112
1113impl std::fmt::Debug for LoginRequest<'_> {
1114    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1115        f.debug_struct("LoginRequest")
1116            .field("cdp_url", &self.cdp_url)
1117            .field("phone", &"<redacted>")
1118            .field("channel", &self.channel)
1119            .finish_non_exhaustive()
1120    }
1121}
1122
1123/// A phone login as a state machine, for a daemon: [`start`](Self::start)
1124/// sends ONE challenge (after the injected [`SendBudget`] allows it), then
1125/// [`submit_code`](Self::submit_code) or [`poll`](Self::poll) until the
1126/// state is terminal, or [`cancel`](Self::cancel). Writes no files and logs
1127/// nothing. The isolated browser context is disposed when the login ends;
1128/// call `cancel` when abandoning a driver so it is disposed promptly (an
1129/// abandoned one is otherwise only cleaned up by the browser).
1130#[derive(Debug)]
1131pub struct LoginDriver {
1132    pending: Option<PendingLogin>,
1133    previous: Session,
1134    state: LoginState,
1135}
1136
1137fn failed(e: &Error) -> LoginState {
1138    LoginState::Failed(format!("{}: {}", e.kind.as_str(), e.message))
1139}
1140
1141impl LoginDriver {
1142    /// Open the login page in a fresh isolated context and send one
1143    /// challenge. Never fails: errors become [`LoginState::Failed`].
1144    pub async fn start(req: LoginRequest<'_>, budget: &dyn SendBudget) -> Self {
1145        let previous = req.previous.clone().unwrap_or_default();
1146        let opts = StartOpts {
1147            cdp_base: req.cdp_url,
1148            phone: req.phone,
1149            channel: req.channel,
1150            copy_browser_address: req.copy_browser_address,
1151        };
1152        match start(opts, budget).await {
1153            Ok(p) => {
1154                let state = Self::waiting(&p, None);
1155                Self {
1156                    pending: Some(p),
1157                    previous,
1158                    state,
1159                }
1160            }
1161            Err(e) => Self {
1162                pending: None,
1163                previous,
1164                state: failed(&e),
1165            },
1166        }
1167    }
1168
1169    fn waiting(p: &PendingLogin, error: Option<String>) -> LoginState {
1170        match p.kind {
1171            PendingKind::OtpSent => LoginState::AwaitingCode {
1172                phone_masked: p.phone_masked.clone(),
1173                channel: p.channel_sent.clone(),
1174                expires_at: p.expires_at,
1175                error,
1176            },
1177            PendingKind::ApprovalPending => LoginState::AwaitingApproval {
1178                phone_masked: p.phone_masked.clone(),
1179                expires_at: p.expires_at,
1180            },
1181        }
1182    }
1183
1184    pub fn state(&self) -> &LoginState {
1185        &self.state
1186    }
1187
1188    /// The pending login (no secrets), e.g. to show its expiry.
1189    pub fn pending(&self) -> Option<&PendingLogin> {
1190        self.pending.as_ref()
1191    }
1192
1193    /// After an error: still waiting if the login is still pending (e.g. a
1194    /// rejected code), else failed.
1195    fn after_error(&mut self, e: &Error) {
1196        self.state = match &self.pending {
1197            Some(p) => Self::waiting(p, Some(e.message.clone())),
1198            None => failed(e),
1199        };
1200    }
1201
1202    /// Type `code` into the page. Only valid while awaiting a code.
1203    pub async fn submit_code(&mut self, code: &str) -> &LoginState {
1204        if !matches!(self.state, LoginState::AwaitingCode { .. }) {
1205            return &self.state;
1206        }
1207        match finish_with_otp(&mut self.pending, code, &self.previous).await {
1208            Ok(s) => self.state = LoginState::Done(s),
1209            Err(e) => self.after_error(&e),
1210        }
1211        &self.state
1212    }
1213
1214    /// Wait up to `wait` for the in-app approval (keep it short and call
1215    /// again; e.g. 5 s). Only valid while awaiting approval.
1216    pub async fn poll(&mut self, wait: Duration) -> &LoginState {
1217        if !matches!(self.state, LoginState::AwaitingApproval { .. }) {
1218            return &self.state;
1219        }
1220        match wait_for_approval(&mut self.pending, wait, &self.previous).await {
1221            Ok(WaitOutcome::LoggedIn(s)) => self.state = LoginState::Done(s),
1222            Ok(WaitOutcome::StillWaiting) => {}
1223            Err(e) => self.after_error(&e),
1224        }
1225        &self.state
1226    }
1227
1228    /// Dispose the isolated context and end the login (the send still
1229    /// counts against the budget).
1230    pub async fn cancel(&mut self) -> &LoginState {
1231        if !self.state.is_terminal() {
1232            cancel(&mut self.pending).await;
1233            self.state = LoginState::Failed("cancelled".into());
1234        }
1235        &self.state
1236    }
1237
1238    /// The session once [`LoginState::Done`].
1239    pub fn into_session(self) -> Option<Session> {
1240        match self.state {
1241            LoginState::Done(s) => Some(s),
1242            _ => None,
1243        }
1244    }
1245}
1246
1247#[cfg(test)]
1248mod tests {
1249    use super::*;
1250
1251    #[test]
1252    fn splits_and_masks_phone() -> Result<()> {
1253        let (cc, n) = split_phone("+63 900 000 0012")?;
1254        assert_eq!((cc.as_str(), n.as_str()), ("63", "9000000012"));
1255        assert_eq!(mask_phone(&cc, &n), "+63••••••••12");
1256        assert!(!mask_phone(&cc, &n).contains("900"));
1257        let (cc, n) = split_phone("+65-8000-0000")?;
1258        assert_eq!((cc.as_str(), n.as_str()), ("65", "80000000"));
1259        assert!(split_phone("09000000000").is_err());
1260        assert!(split_phone("+1 555 000 0000").is_err());
1261        assert!(split_phone("+63 12").is_err());
1262        assert_eq!(mask_phone("63", "1"), "+631");
1263        Ok(())
1264    }
1265
1266    #[test]
1267    fn channels() -> Result<()> {
1268        assert_eq!(Channel::parse("APP")?, Channel::App);
1269        assert_eq!(Channel::parse("whatsapp")?, Channel::Whatsapp);
1270        assert!(Channel::parse("email").is_err());
1271        let offered = vec![
1272            "PUSH_V2".to_string(),
1273            "CHANNEL_OPTIMIZED_PHONE_OTP".to_string(),
1274        ];
1275        assert_eq!(pick_option(Channel::App, &offered), Some("PUSH_V2"));
1276        assert_eq!(
1277            pick_option(Channel::Sms, &offered),
1278            Some("CHANNEL_OPTIMIZED_PHONE_OTP")
1279        );
1280        assert_eq!(pick_option(Channel::Sms, &["PUSH".to_string()]), None);
1281        Ok(())
1282    }
1283
1284    #[test]
1285    fn cooldown_and_hourly_cap() {
1286        let mut log = SendLog::default();
1287        let t0 = 1_000_000;
1288        assert!(log.check(t0).is_ok());
1289        log.record(t0);
1290        // Cooldown.
1291        assert_eq!(log.wait_secs(t0 + 10), 50);
1292        let e = log.check(t0 + 10).err();
1293        assert_eq!(e.as_ref().map(|e| e.kind), Some(ErrorKind::RateLimited));
1294        assert!(log.check(t0 + 60).is_ok());
1295        log.record(t0 + 60);
1296        log.record(t0 + 200);
1297        // Three in the hour: blocked until the first one ages out.
1298        assert_eq!(log.sends_last_hour(t0 + 300), 3);
1299        assert_eq!(log.wait_secs(t0 + 300), 3600 - 300);
1300        assert!(log.check(t0 + 3599).is_err());
1301        assert!(log.check(t0 + 3600).is_ok());
1302        // Recording prunes entries older than an hour.
1303        log.record(t0 + 3700);
1304        assert_eq!(log.sends.len(), 2);
1305    }
1306
1307    fn pending() -> PendingLogin {
1308        PendingLogin {
1309            kind: PendingKind::OtpSent,
1310            cdp_base: "http://127.0.0.1:9222".into(),
1311            browser_context_id: "CTX".into(),
1312            target_id: "TAB".into(),
1313            created_at: 100,
1314            expires_at: 100 + PENDING_TTL_SECS,
1315            phone_masked: mask_phone("63", "9000000012"),
1316            channel_requested: Channel::Sms,
1317            channel_sent: Some("SMS".into()),
1318            copy_browser_address: false,
1319        }
1320    }
1321
1322    #[test]
1323    fn pending_serializes_without_phone() -> Result<()> {
1324        let p = pending();
1325        let raw = serde_json::to_string(&p)?;
1326        assert!(!raw.contains("9000000012"));
1327        let back: PendingLogin = serde_json::from_str(&raw)?;
1328        assert_eq!(back.kind, PendingKind::OtpSent);
1329        assert!(!back.copy_browser_address);
1330        assert!(!back.is_expired(200));
1331        assert!(back.is_expired(100 + PENDING_TTL_SECS));
1332        assert_eq!(back.summary(160)["expires_in_secs"], json!(540));
1333        let j = pending_json(&back, 160);
1334        assert_eq!(j["kind"], json!("otp_sent"));
1335        assert_eq!(j["expires_at"], json!(100 + PENDING_TTL_SECS));
1336        assert_eq!(j["next"], json!("grab auth login --otp <code>"));
1337        let approval = PendingLogin {
1338            kind: PendingKind::ApprovalPending,
1339            ..back
1340        };
1341        assert_eq!(
1342            pending_json(&approval, 160)["next"],
1343            json!("grab auth login --poll")
1344        );
1345        // Older pending files (before the flag) keep copying the address.
1346        let mut v = serde_json::to_value(&p)?;
1347        v.as_object_mut().map(|o| o.remove("copy_browser_address"));
1348        assert!(serde_json::from_value::<PendingLogin>(v)?.copy_browser_address);
1349        Ok(())
1350    }
1351
1352    #[tokio::test]
1353    async fn memory_budget_enforces_limits() -> Result<()> {
1354        let b = MemorySendBudget::default();
1355        b.check(1000).await?;
1356        b.record(1000).await?;
1357        let e = b.check(1010).await.err();
1358        assert_eq!(e.map(|e| e.kind), Some(ErrorKind::RateLimited));
1359        assert_eq!(b.snapshot().sends, vec![1000]);
1360        Ok(())
1361    }
1362
1363    #[tokio::test]
1364    async fn driver_never_sends_when_budget_is_spent() -> Result<()> {
1365        let b = MemorySendBudget::default();
1366        b.record(jwt::now()).await?;
1367        // The budget refuses before any browser is contacted (the CDP URL
1368        // is unroutable, so reaching it would fail differently).
1369        let d = LoginDriver::start(
1370            LoginRequest {
1371                cdp_url: "http://127.0.0.1:1",
1372                phone: "+63 900 000 0012",
1373                channel: Channel::App,
1374                previous: None,
1375                copy_browser_address: false,
1376            },
1377            &b,
1378        )
1379        .await;
1380        match d.state() {
1381            LoginState::Failed(r) => {
1382                assert!(r.starts_with("rate_limited"), "{r}");
1383                assert!(!r.contains("0012"));
1384            }
1385            other => panic!("unexpected {other:?}"),
1386        }
1387        assert_eq!(b.snapshot().sends.len(), 1);
1388        Ok(())
1389    }
1390
1391    #[tokio::test]
1392    async fn driver_rejects_bad_phone_without_sending() {
1393        let b = MemorySendBudget::default();
1394        let mut d = LoginDriver::start(
1395            LoginRequest {
1396                cdp_url: "http://127.0.0.1:1",
1397                phone: "0900",
1398                channel: Channel::Sms,
1399                previous: None,
1400                copy_browser_address: false,
1401            },
1402            &b,
1403        )
1404        .await;
1405        assert!(matches!(d.state(), LoginState::Failed(_)));
1406        assert!(b.snapshot().sends.is_empty());
1407        // Steps on a terminal driver are no-ops.
1408        assert!(matches!(
1409            d.submit_code("123456").await,
1410            LoginState::Failed(_)
1411        ));
1412        assert!(matches!(d.cancel().await, LoginState::Failed(_)));
1413        assert!(d.into_session().is_none());
1414    }
1415
1416    #[test]
1417    fn dry_run_sends_nothing_and_reports_limits() {
1418        let mut log = SendLog::default();
1419        log.record(1000);
1420        let v = dry_run_plan("+63••••••••12", Channel::App, "http://x:9222", &log, 1030);
1421        assert_eq!(v["kind"], json!("dry_run"));
1422        assert_eq!(v["dry_run"], json!(true));
1423        assert_eq!(v["sent"], json!(false));
1424        assert_eq!(v["phone"], json!("+63••••••••12"));
1425        assert_eq!(v["sends_last_hour"], json!(1));
1426        assert_eq!(v["next_send_allowed_in_secs"], json!(30));
1427        assert!(
1428            v["would_send"]
1429                .as_str()
1430                .unwrap_or_default()
1431                .contains("approval")
1432        );
1433    }
1434
1435    fn probe(host: &str, path: &str, text: &str) -> Value {
1436        json!({"host": host, "path": path, "text": text, "has_phone": false, "options": [], "captcha": false})
1437    }
1438
1439    #[test]
1440    fn classifies_screens() {
1441        let mut phone = probe(
1442            "weblogin.grab.com",
1443            "/passenger/login",
1444            "Sign in to Grab\n+63",
1445        );
1446        phone["has_phone"] = json!(true);
1447        assert_eq!(classify_screen(&phone), Screen::Phone);
1448        assert_eq!(
1449            classify_screen(&probe("weblogin.grab.com", "/passenger/login", "")),
1450            Screen::Loading
1451        );
1452        let mut opts = probe(
1453            "weblogin.grab.com",
1454            "/passenger/challenge-options",
1455            "Choose a preferred way to sign in",
1456        );
1457        opts["options"] = json!(["PUSH_V2", "CHANNEL_OPTIMIZED_PHONE_OTP"]);
1458        assert_eq!(
1459            classify_screen(&opts),
1460            Screen::Options(vec!["PUSH_V2".into(), "CHANNEL_OPTIMIZED_PHONE_OTP".into()])
1461        );
1462        assert_eq!(
1463            classify_screen(&probe(
1464                "weblogin.grab.com",
1465                "/passenger/optimised-sms-otp",
1466                "Check your WhatsApp\nWe've sent a verification code"
1467            )),
1468            Screen::Otp {
1469                channel: Some("WhatsApp".into())
1470            }
1471        );
1472        assert_eq!(
1473            classify_screen(&probe(
1474                "weblogin.grab.com",
1475                "/passenger/push-v2",
1476                "Approve in mobile app"
1477            )),
1478            Screen::Approval
1479        );
1480        assert_eq!(
1481            classify_screen(&probe(
1482                "weblogin.grab.com",
1483                "/passenger/optimised-sms-otp",
1484                "Incorrect code. You can try 2 more times."
1485            )),
1486            Screen::OtpRejected
1487        );
1488        assert!(matches!(
1489            classify_screen(&probe(
1490                "weblogin.grab.com",
1491                "/passenger/x",
1492                "Account temporarily blocked"
1493            )),
1494            Screen::Problem(_)
1495        ));
1496        assert_eq!(
1497            classify_screen(&probe("weblogin.grab.com", "/passenger/web-selfie", "")),
1498            Screen::StepUp("web-selfie".into())
1499        );
1500        let mut cap = probe("weblogin.grab.com", "/passenger/login", "");
1501        cap["captcha"] = json!(true);
1502        assert_eq!(classify_screen(&cap), Screen::StepUp("recaptcha".into()));
1503        assert_eq!(
1504            classify_screen(&probe("food.grab.com", "/ph/en/", "")),
1505            Screen::Done
1506        );
1507        assert_eq!(
1508            classify_screen(&probe("food.grab.com", "/auth/callback", "")),
1509            Screen::Loading
1510        );
1511        // Text fallback if routes are renamed.
1512        assert_eq!(
1513            classify_screen(&probe(
1514                "weblogin.grab.com",
1515                "/passenger/new",
1516                "Check your SMS"
1517            )),
1518            Screen::Otp {
1519                channel: Some("SMS".into())
1520            }
1521        );
1522    }
1523
1524    #[test]
1525    fn finds_tracking_tabs() {
1526        let pages = vec![
1527            PageInfo {
1528                id: "1".into(),
1529                url: "https://food.grab.com/ph/en/order/001-ABC".into(),
1530            },
1531            PageInfo {
1532                id: "2".into(),
1533                url: "https://food.grab.com/ph/en/restaurants".into(),
1534            },
1535            PageInfo {
1536                id: "3".into(),
1537                url: "https://example.com/order/1".into(),
1538            },
1539        ];
1540        let tabs = order_tracking_tabs(&pages);
1541        assert_eq!(tabs, vec!["food.grab.com/ph/en/order/001-ABC".to_string()]);
1542        assert!(tracking_tab_warning(&tabs).is_some_and(|w| w.contains("logout")));
1543        assert!(tracking_tab_warning(&[]).is_none());
1544    }
1545
1546    #[test]
1547    fn stale_tabs_are_dropped() {
1548        let a = "food.grab.com/ph/en/order/A".to_string();
1549        let b = "food.grab.com/ph/en/order/B".to_string();
1550        assert_eq!(
1551            still_open(&[a.clone(), b.clone()], std::slice::from_ref(&b)),
1552            vec![b.clone()]
1553        );
1554        assert!(still_open(&[a], &[]).is_empty());
1555    }
1556}