Skip to main content

grabcli_core/
ctx.rs

1//! The runtime context shared by every command.
2//!
3//! Owns the loaded config, session and HTTP client, the output format, and the
4//! hydra refresh policy. Service code calls [`Context::call`] with a prepared
5//! request; the context attaches the hydra token, and on a hydra rejection it
6//! re-mints once (native or via CDP, per config) and retries.
7//!
8//! It also owns the response [`Cache`]; read-only browse calls go through
9//! [`Context::cached_call`]. Never route cart/quote/order/auth calls through
10//! the cache (see the safety notes in [`crate::cache`]).
11
12use std::sync::Arc;
13
14use serde_json::Value;
15
16use crate::cache::{Cache, CacheKey, CacheMode, Fetched};
17use crate::config::{Config, DEFAULT_CDP, HydraMode};
18use crate::error::{Error, ErrorKind, Result};
19use crate::http::{Allowlist, Client, Hydra, Request};
20use crate::jwt;
21use crate::output::Output;
22use crate::paths;
23use crate::session::{LOCATION_COOKIE, Session};
24use crate::{cdp, hydra, sso};
25
26/// How hydra should be minted, resolved from config + available key.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28enum ResolvedHydra {
29    Native,
30    Cdp,
31}
32
33pub struct Context {
34    pub config: Config,
35    pub out: Output,
36    session: Session,
37    client: Arc<Client>,
38    /// The service allowlist, kept so the client can be rebuilt after a refresh.
39    allow: Allowlist,
40    /// Skew applied when deciding if the stored hydra token is still fresh.
41    hydra_skew: i64,
42    /// Response cache for read-only browse data.
43    cache: Cache,
44    /// Whether a silent session refresh was already tried by this process
45    /// (it's tried at most once, proactively or after a 401).
46    refresh_tried: bool,
47}
48
49/// Refresh proactively when the logged-in `gfc_session` has less than this
50/// left (it lives 2 h).
51const REFRESH_MARGIN_SECS: i64 = 15 * 60;
52
53impl Context {
54    /// Build a context for a command that makes Grab calls. `allow` is the
55    /// service's URL allowlist.
56    pub fn new(out: Output, allow: Allowlist) -> Result<Self> {
57        let config = Config::load(&paths::config_path()?)?;
58        let session = Session::load_default()?;
59        let client = Arc::new(Client::new(&session, &config, allow.clone())?);
60        let cache = Cache::from_config(&config.cache, CacheMode::Normal);
61        Ok(Self {
62            cache,
63            config,
64            out,
65            session,
66            client,
67            allow,
68            hydra_skew: 30,
69            refresh_tried: false,
70        })
71    }
72
73    /// Refresh the short-lived `gfc_session` silently through the SSO cookie
74    /// ([`crate::sso`]), persist it and rebuild the HTTP client. Fails with
75    /// `auth_expired` ("run `grab auth login`") when the SSO is dead. Never
76    /// triggers an OTP or push.
77    pub async fn refresh_session(&mut self) -> Result<()> {
78        self.refresh_tried = true;
79        let ua = self
80            .config
81            .user_agent
82            .clone()
83            .or_else(|| self.session.user_agent.clone())
84            .unwrap_or_else(|| crate::http::DEFAULT_UA.to_string());
85        sso::silent_refresh(&mut self.session, &ua).await?;
86        self.session.save_default()?;
87        self.client = Arc::new(Client::new(
88            &self.session,
89            &self.config,
90            self.allow.clone(),
91        )?);
92        Ok(())
93    }
94
95    /// Copy only the saved delivery address (`location` cookie) from the
96    /// browser's default context at `cdp_base` into the session, leaving
97    /// every other cookie (auth included) alone. `Ok(false)` if the browser
98    /// has no `location` cookie. The value is never printed.
99    pub async fn restore_address_from_browser(&mut self, cdp_base: &str) -> Result<bool> {
100        let (cookies, _) = cdp::default_context_cookies(cdp_base).await?;
101        if !self
102            .session
103            .replace_named_cookies(LOCATION_COOKIE, &cookies)
104        {
105            return Ok(false);
106        }
107        self.session.save_default()?;
108        self.client = Arc::new(Client::new(
109            &self.session,
110            &self.config,
111            self.allow.clone(),
112        )?);
113        Ok(true)
114    }
115
116    /// Replace the saved delivery address (`location` cookie) with `loc`
117    /// (the cookie's JSON) and persist the session. Local only: the cookie
118    /// is what the web app itself writes; nothing is sent to Grab.
119    pub fn set_location(&mut self, loc: &Value) -> Result<()> {
120        let json = serde_json::to_string(loc).map_err(|e| Error::parse(e.to_string()))?;
121        let value = urlencoding::encode(&json).into_owned();
122        self.session.set_location_cookie(&value);
123        self.session.save_default()?;
124        self.client = Arc::new(Client::new(
125            &self.session,
126            &self.config,
127            self.allow.clone(),
128        )?);
129        Ok(())
130    }
131
132    /// Best-effort follow-up after a login or refresh saved a session: fill
133    /// in a missing saved address from the browser, and mint hydra so the
134    /// next quote doesn't have to. Never fails; returns notes for the user.
135    pub async fn after_login(&mut self) -> Vec<String> {
136        let mut notes = Vec::new();
137        if self.session.cookie(LOCATION_COOKIE).is_none() {
138            let base = self.cdp_base();
139            match self.restore_address_from_browser(&base).await {
140                Ok(true) => notes.push("copied the saved delivery address from the browser".into()),
141                Ok(false) => notes.push(
142                    "no saved delivery address in the session or the browser; set one on \
143                     food.grab.com, then `grab auth import --address-only`"
144                        .into(),
145                ),
146                Err(_) => notes.push(
147                    "no saved delivery address and the browser was unreachable; later run \
148                     `grab auth import --address-only`"
149                        .into(),
150                ),
151            }
152        }
153        if !self.has_mart_session()
154            && let Err(e) = self.login_mart().await
155        {
156            notes.push(if e.kind == ErrorKind::AuthMissing {
157                format!("GrabMart: {}", e.message)
158            } else {
159                format!(
160                    "GrabMart session not set up now ({}); `grab mart quote` retries it",
161                    e.kind.as_str()
162                )
163            });
164        }
165        if !self.session.hydra_valid(self.hydra_skew)
166            && let Err(e) = self.mint_hydra().await
167        {
168            notes.push(format!(
169                "hydra not minted now ({}); commands mint it on demand",
170                e.kind.as_str()
171            ));
172        }
173        notes
174    }
175
176    /// Log in to GrabMart's own web client silently through the SSO cookie
177    /// ([`sso::Service::Mart`]): the protected mart API doesn't accept
178    /// food's `gfc_session`. Persists the session and rebuilds the client.
179    /// Never triggers an OTP or push.
180    pub async fn login_mart(&mut self) -> Result<()> {
181        let ua = self
182            .config
183            .user_agent
184            .clone()
185            .or_else(|| self.session.user_agent.clone())
186            .unwrap_or_else(|| crate::http::DEFAULT_UA.to_string());
187        sso::silent_login(sso::Service::Mart, &mut self.session, &ua).await?;
188        self.session.save_default()?;
189        self.client = Arc::new(Client::new(
190            &self.session,
191            &self.config,
192            self.allow.clone(),
193        )?);
194        Ok(())
195    }
196
197    /// Whether the session has a GrabMart web session cookie.
198    pub fn has_mart_session(&self) -> bool {
199        !sso::mart_session_cookies(&self.session).is_empty()
200    }
201
202    /// Whether the logged-in `gfc_session` is about to expire (or has).
203    /// A missing/anonymous one is left to the 401 path, so commands that
204    /// don't need a login don't pay for a refresh attempt.
205    fn session_expiring(&self) -> bool {
206        self.session.gfc_logged_in()
207            && self
208                .session
209                .gfc_expires_at()
210                .is_some_and(|e| e - jwt::now() < REFRESH_MARGIN_SECS)
211    }
212
213    /// Set the cache mode (from `--no-cache` / `--refresh`). `GRAB_NO_CACHE`
214    /// and `[cache] enabled = false` still force it off.
215    #[must_use]
216    pub fn with_cache_mode(mut self, mode: CacheMode) -> Self {
217        self.cache = Cache::from_config(&self.config.cache, mode);
218        self
219    }
220
221    /// The response cache. Clone it to call [`Cache::get_or_fetch`] with a
222    /// closure that borrows this context.
223    pub fn cache(&self) -> &Cache {
224        &self.cache
225    }
226
227    /// [`call`](Self::call) through the cache: serve a fresh entry for `key`,
228    /// else make the call and store the raw response. Only for read-only
229    /// browse requests — never carts, quotes, orders, tracking or auth.
230    pub async fn cached_call(&mut self, key: &CacheKey, req: &Request) -> Result<Value> {
231        let cache = self.cache.clone();
232        cache.get_or_fetch(key, || self.call(req)).await
233    }
234
235    /// [`cached_call`](Self::cached_call) that also reports whether the
236    /// response came from the cache and when it was fetched (for "cached 4m
237    /// ago" markers).
238    pub async fn cached_call_meta(
239        &mut self,
240        key: &CacheKey,
241        req: &Request,
242    ) -> Result<Fetched<Value>> {
243        let cache = self.cache.clone();
244        cache.get_or_fetch_meta(key, || self.call(req)).await
245    }
246
247    pub fn session(&self) -> &Session {
248        &self.session
249    }
250
251    pub fn cdp_base(&self) -> String {
252        self.config
253            .cdp
254            .clone()
255            .unwrap_or_else(|| DEFAULT_CDP.to_string())
256    }
257
258    fn resolve_hydra_mode(&self) -> ResolvedHydra {
259        let key_present = self.hydra_key_pem().is_some();
260        match self.config.hydra_mode.unwrap_or_default() {
261            HydraMode::Native => ResolvedHydra::Native,
262            HydraMode::Cdp => ResolvedHydra::Cdp,
263            HydraMode::Auto => {
264                if key_present {
265                    ResolvedHydra::Native
266                } else {
267                    ResolvedHydra::Cdp
268                }
269            }
270        }
271    }
272
273    fn hydra_key_path(&self) -> Option<std::path::PathBuf> {
274        if let Some(p) = &self.config.hydra_key {
275            return Some(p.clone());
276        }
277        let default = paths::config_dir().ok()?.join("hydra-key.pem");
278        default.exists().then_some(default)
279    }
280
281    fn hydra_key_pem(&self) -> Option<String> {
282        std::fs::read_to_string(self.hydra_key_path()?).ok()
283    }
284
285    /// Ensure a usable hydra token exists, minting/refreshing if needed and
286    /// allowed. Persists the refreshed session. Returns the token.
287    pub async fn ensure_hydra(&mut self) -> Result<String> {
288        if self.session.hydra_valid(self.hydra_skew)
289            && let Some(tok) = &self.session.hydra_jwt
290        {
291            return Ok(tok.clone());
292        }
293        self.mint_hydra().await
294    }
295
296    /// Mint a fresh hydra token regardless of the current one.
297    pub async fn mint_hydra(&mut self) -> Result<String> {
298        match self.resolve_hydra_mode() {
299            ResolvedHydra::Native => {
300                let pem = self.hydra_key_pem().ok_or_else(|| {
301                    Error::new(
302                        ErrorKind::HydraExpired,
303                        "native hydra minting needs a signing key at \
304                         <config>/hydra-key.pem (see docs/hydra.md); or set \
305                         hydra_mode = \"cdp\"",
306                    )
307                })?;
308                let ver = hydra::DEFAULT_VER;
309                let (tok, exp) = hydra::mint_native(&pem, &hydra::DeviceFields::random(), ver)?;
310                self.session.hydra_jwt = Some(tok.clone());
311                self.session.hydra_exp = Some(exp);
312                self.session.save_default()?;
313                Ok(tok)
314            }
315            ResolvedHydra::Cdp => {
316                let base = self.cdp_base();
317                let page = self
318                    .config
319                    .hydra_page
320                    .clone()
321                    .unwrap_or_else(|| "https://food.grab.com/ph/en/".to_string());
322                cdp::mint_hydra(&base, &page, &mut self.session).await?;
323                self.session.save_default()?;
324                self.session.hydra_jwt.clone().ok_or_else(|| {
325                    Error::new(ErrorKind::HydraExpired, "hydra mint produced no token")
326                })
327            }
328        }
329    }
330
331    /// Make an allowlisted call, attaching hydra when required and retrying
332    /// once if Grab rejects the token (and `auto_refresh` is on).
333    pub async fn call(&mut self, req: &Request) -> Result<Value> {
334        // Proactive, best-effort: if it fails the request still goes out and
335        // a 401 reports the real problem.
336        if self.config.auto_refresh() && !self.refresh_tried && self.session_expiring() {
337            let _ = self.refresh_session().await;
338        }
339        let token = if req.hydra == Hydra::Required {
340            Some(self.ensure_hydra().await?)
341        } else {
342            None
343        };
344
345        match self.client.send(req, token.as_deref()).await {
346            Err(e)
347                if e.kind == ErrorKind::HydraExpired
348                    && req.hydra == Hydra::Required
349                    && self.config.auto_refresh() =>
350            {
351                // Re-mint once and retry.
352                let fresh = self.mint_hydra().await?;
353                self.client.send(req, Some(&fresh)).await
354            }
355            Err(e)
356                if e.kind == ErrorKind::AuthExpired
357                    && req.refreshable
358                    && !self.refresh_tried
359                    && self.config.auto_refresh() =>
360            {
361                // The short-lived session expired: refresh it from the
362                // long-lived cookies and retry the request exactly once. Never
363                // taken for non-idempotent calls (`refreshable == false`).
364                self.refresh_session().await?;
365                let token = if req.hydra == Hydra::Required {
366                    Some(self.ensure_hydra().await?)
367                } else {
368                    None
369                };
370                self.client.send(req, token.as_deref()).await
371            }
372            other => other,
373        }
374    }
375
376    /// Status snapshot for `grab auth status` — cookie names and expiries, never
377    /// cookie values. The saved unit and driver note are shown to the user.
378    pub fn auth_status(&self) -> Value {
379        let now = jwt::now();
380        let loc = crate::location::Location::from_session(&self.session).ok();
381        let cookies: Vec<Value> = self
382            .session
383            .cookies
384            .iter()
385            .map(|c| {
386                let exp = if c.expires > 0.0 {
387                    Some(c.expires as i64)
388                } else {
389                    None
390                };
391                serde_json::json!({
392                    "name": c.name,
393                    "domain": c.domain,
394                    "expires": exp,
395                    "expires_in_secs": exp.map(|e| e - now),
396                    "expired": exp.is_some_and(|e| e <= now),
397                })
398            })
399            .collect();
400        serde_json::json!({
401            "session_path": paths::session_path().ok().map(|p| p.display().to_string()),
402            "cookies": cookies,
403            "gfc_session_present": self.session.cookie("gfc_session").is_some(),
404            "gfc_session_logged_in": self.session.gfc_logged_in(),
405            "gfc_session_exp": self.session.gfc_expires_at(),
406            "gfc_session_expires_in_secs": self.session.gfc_expires_at().map(|e| e - now),
407            "hydra_exp": self.session.hydra_exp,
408            "hydra_expires_in_secs": self.session.hydra_exp.map(|e| e - now),
409            "hydra_valid": self.session.hydra_valid(self.hydra_skew),
410            "web_app_version_present": self.session.web_app_version.is_some(),
411            "payment_type_id_present":
412                self.session.payment_type_id.is_some() || self.config.payment_type_id.is_some(),
413            "has_saved_address": self.session.cookie("location").is_some(),
414            "address_detail": loc.as_ref().and_then(|l| l.address_detail.clone()),
415            "note_to_driver": loc.as_ref().and_then(|l| l.note_to_driver.clone()),
416        })
417    }
418}