Skip to main content

grabcli_core/
ctx.rs

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