Skip to main content

cratefield_adapter_fcm/
lib.rs

1//! `cratefield-adapter-fcm`: the [`Push`] port over Firebase Cloud Messaging
2//! (issue #179), on the **HTTP v1** API. Uses the runtime's [`HttpClient`]
3//! and [`Clock`] ports — no Firebase Admin SDK (there is none for Rust and
4//! none is needed), no `reqwest`, no OpenSSL — so the same adapter runs on
5//! Workers (`worker::Fetch`) and natively.
6//!
7//! **Why v1 only.** Google shut the legacy `fcm/send` API down in June 2024.
8//! `POST /v1/projects/{project_id}/messages:send` with an OAuth 2.0 bearer
9//! token is the only supported server path, and it is plain HTTPS and JSON.
10//!
11//! **Recipients.** It serves [`Recipient::Fcm`] and returns
12//! [`PushError::unsupported_recipient`] for APNs and Web Push. Google-free
13//! Android is *not* this adapter: that is UnifiedPush, which the Web Push
14//! adapter carries. A venture with more than one transport wires
15//! [`RoutingPush`](cratefield_core::RoutingPush), which dispatches by variant
16//! (ADR 0015).
17//!
18//! **Authentication.** A Google service account signs an RS256 assertion —
19//! `{iss: client_email, scope: firebase.messaging, aud: token_uri, iat, exp}`
20//! — and trades it at `oauth2.googleapis.com/token` for a bearer token good
21//! for an hour. Signing is [`Rs256Signer`] and the token is held in a
22//! [`CachedToken`], both from [`cratefield_push_auth`] (issue #178), so this
23//! crate re-implements no JWT. The bearer token is cached for the lifetime
24//! Google states less a five-minute safety margin, capped by
25//! [`ACCESS_TOKEN_TTL`]. A `401 UNAUTHENTICATED` drops the cached token and
26//! retries the send **once**, mirroring the APNs expired-provider-token path.
27//!
28//! **Degraded mode.** [`Fcm::not_configured`] reports
29//! [`PushOutcome::NotConfigured`] without any network call, the same contract
30//! [`Resend`](cratefield_core::Mailer) uses when its key is absent, so a
31//! venture with no Firebase credentials still builds and runs. It answers
32//! that only for the transport it serves: an APNs or Web Push recipient is
33//! still [`PushError::unsupported_recipient`], configured or not.
34//!
35//! **Verification.** Token exchange, payload mapping and error mapping are
36//! unit-tested here against a scripted [`HttpClient`]. The live path against
37//! a real Firebase project and a real Android device is `needs-human`
38//! (issue #186) and is **not** a blocker for merging this crate: it needs a
39//! service-account key, a project and a handset, none of which live in the
40//! repo.
41#![doc = include_str!("../README.md")]
42#![forbid(unsafe_code)]
43
44use std::sync::Arc;
45use std::time::Duration;
46
47use async_trait::async_trait;
48use bytes::Bytes;
49use cratefield_core::{
50    Clock, HttpClient, Notification, Priority, Push, PushError, PushOutcome, Recipient,
51    retry_after, ttl_secs,
52};
53use cratefield_push_auth::{CachedToken, Rs256Signer};
54use http::header::{AUTHORIZATION, CONTENT_TYPE};
55use http::{Request, StatusCode};
56use serde::{Deserialize, Deserializer};
57use serde_json::{Map, Value, json};
58
59/// The ceiling on how long an exchanged bearer token is reused. Google issues
60/// them for an hour; the adapter keeps `expires_in` less
61/// [`EXPIRY_MARGIN`], and never longer than this, so a token is always
62/// re-exchanged with minutes to spare.
63#[allow(clippy::duration_suboptimal_units)] // `from_mins` is not const-stable on 1.98
64pub const ACCESS_TOKEN_TTL: Duration = Duration::from_secs(3_300);
65
66/// How far short of Google's stated expiry a token is retired, so a send that
67/// starts just before the boundary cannot finish just after it.
68#[allow(clippy::duration_suboptimal_units)] // `from_mins` is not const-stable on 1.98
69pub const EXPIRY_MARGIN: Duration = Duration::from_secs(300);
70
71/// How long the signed service-account assertion claims to be valid. Google's
72/// documented maximum for the JWT-bearer grant is one hour; the assertion is
73/// spent immediately on the token endpoint, so this is a bound, not a cache.
74const ASSERTION_TTL_SECS: i64 = 3_600;
75
76/// The OAuth scope FCM sends are authorised under.
77const SCOPE: &str = "https://www.googleapis.com/auth/firebase.messaging";
78
79/// The `grant_type` for RFC 7523's JWT-bearer flow.
80const JWT_BEARER_GRANT: &str = "urn:ietf:params:oauth:grant-type:jwt-bearer";
81
82/// Google's token endpoint, and the default when a service-account JSON does
83/// not carry a `token_uri` of its own.
84pub const DEFAULT_TOKEN_URI: &str = "https://oauth2.googleapis.com/token";
85
86/// The FCM HTTP v1 host.
87const FCM_BASE: &str = "https://fcm.googleapis.com";
88
89// The `errorCode` values of `google.firebase.fcm.v1.FcmError`. Mapping keys
90// off these rather than the HTTP status, because the status alone cannot tell
91// a dead registration token (404 `UNREGISTERED`, prune it) from a project
92// that does not exist (404, a configuration bug), nor a wrong-project token
93// (403 `SENDER_ID_MISMATCH`) from a missing APNs credential
94// (401 `THIRD_PARTY_AUTH_ERROR`).
95const UNREGISTERED: &str = "UNREGISTERED";
96const INVALID_ARGUMENT: &str = "INVALID_ARGUMENT";
97const SENDER_ID_MISMATCH: &str = "SENDER_ID_MISMATCH";
98const THIRD_PARTY_AUTH_ERROR: &str = "THIRD_PARTY_AUTH_ERROR";
99/// The pre-rename spelling of [`THIRD_PARTY_AUTH_ERROR`]; Google still emits
100/// it for some projects.
101const APNS_AUTH_ERROR: &str = "APNS_AUTH_ERROR";
102const QUOTA_EXCEEDED: &str = "QUOTA_EXCEEDED";
103const UNAVAILABLE: &str = "UNAVAILABLE";
104const INTERNAL: &str = "INTERNAL";
105
106// The RFC 6749 §5.2 `error` codes the token endpoint answers with that no
107// amount of retrying can fix: the assertion was signed with a key Google no
108// longer accepts, or the service account is not allowed this grant. Both are
109// configuration, and an outbox retrying them forever is worse than one
110// rejection in the log.
111const INVALID_GRANT: &str = "invalid_grant";
112const UNAUTHORIZED_CLIENT: &str = "unauthorized_client";
113
114/// The credentials a venture reads from its secrets to reach FCM.
115///
116/// [`Self::from_service_account_json`] takes the file Firebase hands over
117/// whole, which is the simplest thing for an operator to paste into a secret.
118///
119/// [`Debug`](#impl-Debug-for-FcmCredentials) is hand-written and never prints
120/// the private key: core's log redaction keys off the *field name*
121/// ([`is_secret_field`](cratefield_core::is_secret_field)) and cannot see
122/// inside a `{:?}` of this struct.
123#[derive(Clone)]
124pub struct FcmCredentials {
125    /// The service account's address (`…@….iam.gserviceaccount.com`), the
126    /// assertion's `iss`.
127    pub client_email: String,
128    /// The service account's RSA private key, PEM. Google ships PKCS#8
129    /// (`BEGIN PRIVATE KEY`); the legacy PKCS#1 form is accepted too.
130    pub private_key_pem: String,
131    /// The Firebase project id, which is the `{project_id}` in the send URL.
132    pub project_id: String,
133    /// Google's token endpoint. Comes from the service-account JSON's
134    /// `token_uri`; [`DEFAULT_TOKEN_URI`] when it has none.
135    pub token_uri: String,
136}
137
138impl FcmCredentials {
139    /// Parses a Google service-account JSON file — the whole thing, as
140    /// downloaded from the Firebase console.
141    ///
142    /// # Errors
143    ///
144    /// [`FcmConfigError::ServiceAccount`] if the JSON does not parse or is
145    /// missing `client_email`, `private_key` or `project_id`.
146    pub fn from_service_account_json(json: &str) -> Result<Self, FcmConfigError> {
147        #[derive(Deserialize)]
148        struct ServiceAccount {
149            client_email: Option<String>,
150            private_key: Option<String>,
151            project_id: Option<String>,
152            token_uri: Option<String>,
153        }
154
155        let account: ServiceAccount = serde_json::from_str(json)
156            .map_err(|err| FcmConfigError::ServiceAccount(err.to_string()))?;
157        let field = |name: &str, value: Option<String>| {
158            value
159                .filter(|value| !value.trim().is_empty())
160                .ok_or_else(|| FcmConfigError::ServiceAccount(format!("missing \"{name}\"")))
161        };
162        Ok(Self {
163            client_email: field("client_email", account.client_email)?,
164            private_key_pem: field("private_key", account.private_key)?,
165            project_id: field("project_id", account.project_id)?,
166            token_uri: account
167                .token_uri
168                .filter(|uri| !uri.trim().is_empty())
169                .unwrap_or_else(|| DEFAULT_TOKEN_URI.to_owned()),
170        })
171    }
172}
173
174impl std::fmt::Debug for FcmCredentials {
175    /// Never prints the private key.
176    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177        f.debug_struct("FcmCredentials")
178            .field("client_email", &self.client_email)
179            .field("private_key_pem", &"[redacted]")
180            .field("project_id", &self.project_id)
181            .field("token_uri", &self.token_uri)
182            .finish()
183    }
184}
185
186/// Google's token-endpoint reply. `expires_in` is defaulted rather than
187/// required: a reply without it is still a usable token, it just gets no
188/// cache lifetime and is exchanged again on the next send.
189#[derive(Deserialize)]
190struct TokenResponse {
191    access_token: String,
192    #[serde(default, deserialize_with = "lenient_seconds")]
193    expires_in: u64,
194}
195
196/// `expires_in` as either a JSON number or a JSON string.
197///
198/// RFC 6749 §5.1 types it as a number and Google sends one, but the string
199/// form is common enough in the wild — some proxies and some OAuth servers
200/// quote every value — that rejecting it would throw away a working token
201/// over its packaging. Anything that is neither reads as "no stated
202/// lifetime", which the struct already treats as a usable token that is
203/// simply not cached, rather than as a failed exchange.
204fn lenient_seconds<'de, D: Deserializer<'de>>(deserializer: D) -> Result<u64, D::Error> {
205    #[derive(Deserialize)]
206    #[serde(untagged)]
207    enum Seconds {
208        Number(u64),
209        Text(String),
210    }
211
212    Ok(match Option::<Seconds>::deserialize(deserializer)? {
213        Some(Seconds::Number(seconds)) => seconds,
214        Some(Seconds::Text(text)) => text.trim().parse().unwrap_or(0),
215        None => 0,
216    })
217}
218
219/// Building an [`Fcm`] adapter failed.
220#[derive(Debug, thiserror::Error)]
221pub enum FcmConfigError {
222    /// The service-account JSON did not parse, or a field was missing.
223    #[error("invalid FCM service account: {0}")]
224    ServiceAccount(String),
225    /// `private_key` was not an RSA private key in either PEM form.
226    #[error("invalid FCM service-account key: {0}")]
227    Key(String),
228    /// `project_id` is spliced into the send URL, so it has to be a project
229    /// id and not a path.
230    #[error("invalid FCM project id: {0}")]
231    ProjectId(String),
232    /// `token_uri` is where the service account's assertion — a bearer
233    /// credential — is sent, so it must be HTTPS.
234    #[error("invalid FCM token endpoint: {0}")]
235    TokenUri(String),
236}
237
238/// [`Push`] over `POST https://fcm.googleapis.com/v1/projects/{id}/messages:send`.
239pub struct Fcm {
240    inner: Inner,
241}
242
243enum Inner {
244    Live(Box<Live>),
245    NotConfigured,
246}
247
248struct Live {
249    http: Arc<dyn HttpClient>,
250    clock: Arc<dyn Clock>,
251    signer: Rs256Signer,
252    client_email: String,
253    token_uri: String,
254    send_url: String,
255    /// One bearer token for the whole adapter, hence the `()` key (VAPID is
256    /// the variant that keys per push-service origin).
257    token: CachedToken<()>,
258}
259
260impl Fcm {
261    /// Builds a live adapter, parsing the private key up front so a malformed
262    /// one is reported at construction rather than on the first send.
263    ///
264    /// # Errors
265    ///
266    /// - [`FcmConfigError::ServiceAccount`] if `client_email` is blank. It is
267    ///   the assertion's `iss`, and an empty one signs `"iss": ""`, which
268    ///   Google refuses forever — a failure worth naming at construction
269    ///   rather than on every send.
270    /// - [`FcmConfigError::Key`] if `private_key_pem` is not an RSA private
271    ///   key (PKCS#8 or PKCS#1 PEM).
272    /// - [`FcmConfigError::ProjectId`] if the project id is empty or carries
273    ///   anything but ASCII alphanumerics, `-` or `_`.
274    /// - [`FcmConfigError::TokenUri`] if the token endpoint is not HTTPS.
275    pub fn new(
276        http: Arc<dyn HttpClient>,
277        clock: Arc<dyn Clock>,
278        creds: FcmCredentials,
279    ) -> Result<Self, FcmConfigError> {
280        if creds.client_email.trim().is_empty() {
281            // The same defect `from_service_account_json` reports, reported
282            // the same way: hand-built credentials get the check too.
283            return Err(FcmConfigError::ServiceAccount(
284                "missing \"client_email\"".to_owned(),
285            ));
286        }
287        if !is_wellformed_project_id(&creds.project_id) {
288            return Err(FcmConfigError::ProjectId(format!(
289                "{:?} is not a project id",
290                creds.project_id
291            )));
292        }
293        if !creds.token_uri.starts_with("https://") {
294            return Err(FcmConfigError::TokenUri(format!(
295                "{:?} is not https",
296                creds.token_uri
297            )));
298        }
299        let signer = Rs256Signer::from_pem(&creds.private_key_pem)
300            .map_err(|err| FcmConfigError::Key(err.to_string()))?;
301        Ok(Self {
302            inner: Inner::Live(Box::new(Live {
303                http,
304                clock,
305                signer,
306                client_email: creds.client_email,
307                token_uri: creds.token_uri,
308                send_url: format!("{FCM_BASE}/v1/projects/{}/messages:send", creds.project_id),
309                token: CachedToken::new(ACCESS_TOKEN_TTL),
310            })),
311        })
312    }
313
314    /// Builds a live adapter from a Google service-account JSON file.
315    ///
316    /// # Errors
317    ///
318    /// Everything [`Self::new`] returns, plus
319    /// [`FcmConfigError::ServiceAccount`] when the JSON does not parse or is
320    /// missing a field.
321    pub fn from_service_account_json(
322        http: Arc<dyn HttpClient>,
323        clock: Arc<dyn Clock>,
324        json: &str,
325    ) -> Result<Self, FcmConfigError> {
326        Self::new(
327            http,
328            clock,
329            FcmCredentials::from_service_account_json(json)?,
330        )
331    }
332
333    /// A degraded adapter that reports [`PushOutcome::NotConfigured`] without
334    /// any network call — for a venture with no Firebase credentials set.
335    #[must_use]
336    pub fn not_configured() -> Self {
337        Self {
338            inner: Inner::NotConfigured,
339        }
340    }
341}
342
343impl std::fmt::Debug for Fcm {
344    /// Names the endpoint it would post to and nothing else: no key, no
345    /// bearer token, no service-account address.
346    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
347        match &self.inner {
348            Inner::Live(live) => f
349                .debug_struct("Fcm")
350                .field("send_url", &live.send_url)
351                .finish_non_exhaustive(),
352            Inner::NotConfigured => f.write_str("Fcm::not_configured()"),
353        }
354    }
355}
356
357/// Whether a project id can be spliced into the request path as-is.
358///
359/// A Firebase project id is lowercase letters, digits and hyphens; a project
360/// *number* is digits. Anything else — a `/`, a `..`, whitespace, a `%` — is
361/// not an id, and interpolating it would silently retarget the request at
362/// another project's endpoint (or fail far downstream as "could not build
363/// request"). Underscore and uppercase are tolerated because emulator and
364/// staging projects use them and neither can change the path's shape.
365fn is_wellformed_project_id(id: &str) -> bool {
366    !id.is_empty()
367        && id
368            .bytes()
369            .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
370}
371
372impl Live {
373    /// The current bearer token: the cached one while it lives, otherwise a
374    /// fresh exchange at Google's token endpoint.
375    ///
376    /// The cache is read and written around the `await` rather than across it
377    /// ([`CachedToken::cached`] / [`CachedToken::store`]): the exchange is an
378    /// HTTP call and no lock may be held over one. Two sends racing a cold
379    /// cache therefore cost one extra exchange — Google issues both tokens
380    /// and either works, unlike Apple's once-per-20-minutes provider JWT,
381    /// which is why *that* adapter mints under the lock instead.
382    async fn access_token(&self) -> Result<String, PushError> {
383        if let Some(token) = self.token.cached(self.clock.as_ref(), &()) {
384            return Ok(token);
385        }
386        let (token, lifetime) = self.exchange().await?;
387        self.token
388            .store(self.clock.as_ref(), &(), token.clone(), lifetime);
389        Ok(token)
390    }
391
392    /// Signs a service-account assertion and trades it for a bearer token,
393    /// returning the token and how long it may be reused.
394    async fn exchange(&self) -> Result<(String, Duration), PushError> {
395        let now_unix = self.clock.now().unix_timestamp();
396        let assertion = self.signer.sign_jwt(
397            &json!({ "alg": "RS256", "typ": "JWT" }),
398            &json!({
399                "iss": self.client_email,
400                "scope": SCOPE,
401                "aud": self.token_uri,
402                "iat": now_unix,
403                "exp": now_unix.saturating_add(ASSERTION_TTL_SECS),
404            }),
405        );
406        let form = serde_urlencoded::to_string([
407            ("grant_type", JWT_BEARER_GRANT),
408            ("assertion", assertion.as_str()),
409        ])
410        .map_err(|err| PushError::transient(format!("could not encode token request: {err}")))?;
411
412        let request = Request::builder()
413            .method("POST")
414            .uri(&self.token_uri)
415            .header(CONTENT_TYPE, "application/x-www-form-urlencoded")
416            .body(Bytes::from(form))
417            .map_err(|err| PushError::Rejected(format!("could not build token request: {err}")))?;
418        let response = self
419            .http
420            .send(request)
421            .await
422            .map_err(|err| PushError::transient(err.to_string()))?;
423
424        let status = response.status();
425        if !status.is_success() {
426            let (code, detail) = oauth_error(response.body());
427            let message = format!("fcm token exchange {}: {detail}", status.as_u16());
428            // A failure here is the adapter's own credential rather than the
429            // caller's notification, but that does not make all of them
430            // retryable. `invalid_grant` and `unauthorized_client` are
431            // permanent — a revoked or replaced service-account key, or an
432            // account without the grant — and retrying them forever turns a
433            // configuration mistake into an outbox that never drains. A
434            // briefly unavailable token endpoint (5xx,
435            // `temporarily_unavailable`) is the retryable case and stays
436            // retryable, as does anything unrecognised.
437            //
438            // Clock skew also surfaces as `invalid_grant`, which is why the
439            // assertion's `iat` comes from the [`Clock`] port rather than a
440            // wall clock the isolate cannot trust: a runtime whose clock is
441            // right cannot land here by accident.
442            return Err(match code.as_deref() {
443                Some(INVALID_GRANT | UNAUTHORIZED_CLIENT) => PushError::Rejected(message),
444                _ => PushError::transient_after(
445                    message,
446                    retry_after(response.headers(), self.clock.as_ref()),
447                ),
448            });
449        }
450
451        let token: TokenResponse = serde_json::from_slice(response.body())
452            .map_err(|err| PushError::transient(format!("unparseable token response: {err}")))?;
453        if token.access_token.is_empty() {
454            return Err(PushError::transient("token response carried no token"));
455        }
456        // Retire the token early: `expires_in` is when Google stops accepting
457        // it, and a send that starts a second before that finishes after it.
458        let lifetime = Duration::from_secs(token.expires_in).saturating_sub(EXPIRY_MARGIN);
459        Ok((token.access_token, lifetime))
460    }
461
462    /// One `messages:send` attempt with the bearer token it is handed.
463    async fn post_message(
464        &self,
465        token: &str,
466        body: Bytes,
467    ) -> Result<http::Response<Bytes>, PushError> {
468        let request = Request::builder()
469            .method("POST")
470            .uri(&self.send_url)
471            .header(AUTHORIZATION, format!("Bearer {token}"))
472            .header(CONTENT_TYPE, "application/json")
473            .body(body)
474            .map_err(|err| PushError::Rejected(format!("could not build request: {err}")))?;
475        self.http
476            .send(request)
477            .await
478            .map_err(|err| PushError::transient(err.to_string()))
479    }
480}
481
482/// `message.android.priority` for a [`Priority`].
483///
484/// Unlike APNs, FCM allows a high-priority data-only message — that is how an
485/// Android app is woken — so `silent` does not force the value down.
486fn priority_value(priority: Priority) -> &'static str {
487    match priority {
488        Priority::Immediate => "HIGH",
489        Priority::Conserve => "NORMAL",
490    }
491}
492
493/// Whether a value names something to fetch rather than something bundled.
494///
495/// `message.notification.image` is a URL the device downloads;
496/// `android.notification.icon` is the name of a drawable resource *inside the
497/// app*. The port has one `icon` field for both, so which one it means is
498/// decided by whether it looks like a URL — putting a URL in `icon` shows no
499/// icon at all, and a drawable name in `image` shows no image.
500fn is_url(value: &str) -> bool {
501    value.starts_with("https://") || value.starts_with("http://")
502}
503
504/// FCM data values must be **strings**. A caller's nested JSON is serialised
505/// rather than dropped or rejected: `{"room": {"id": 42}}` travels as
506/// `"{\"id\":42}"`, which the app parses back. A string is passed through as
507/// itself, so `"42"` does not become `"\"42\""`.
508fn data_value(value: &Value) -> String {
509    match value {
510        Value::String(text) => text.clone(),
511        other => other.to_string(),
512    }
513}
514
515/// The `message.data` map: the caller's `data` object with every value
516/// stringified, plus `url` where the notification carries one.
517fn build_data(notification: &Notification) -> Map<String, Value> {
518    let mut data = Map::new();
519    // A non-object `data` has no key-value shape to flatten, so there is
520    // nothing to send; the same rule the APNs adapter applies when merging.
521    if let Value::Object(object) = &notification.data {
522        for (key, value) in object {
523            data.insert(key.clone(), Value::String(data_value(value)));
524        }
525    }
526    // `click_action` only exists on the notification block, which a silent
527    // message does not have — so the tap target also travels in `data`, the
528    // same key the APNs and Web Push adapters use. The typed field wins over
529    // a "url" in `data`: it is the field the port documents.
530    if let Some(url) = &notification.url {
531        data.insert("url".to_owned(), Value::String(url.clone()));
532    }
533    data
534}
535
536/// `message.android.notification`: the fields that describe how Android shows
537/// an alert. Absent entirely on a silent message.
538///
539/// `thread_id` is **not** mapped. The port defines it as "groups related
540/// notifications in the UI", which APNs (`thread-id`) and the web
541/// Notification API do; FCM's `android.notification.tag` is not that. A tag
542/// *replaces* the notification already in the drawer, so five messages
543/// sharing a `thread_id` would show as five grouped on iOS and as one on
544/// Android, with four silently destroyed. Android grouping is a client-side
545/// call (`NotificationCompat.Builder.setGroup`) with no field in the HTTP v1
546/// message, and `collapse_id` already carries the caller's coalescing intent
547/// to `android.collapse_key`.
548fn build_android_notification(notification: &Notification) -> Map<String, Value> {
549    let mut block = Map::new();
550    if let Some(category) = &notification.category {
551        // FCM's channel_id is the Android notification channel, which is what
552        // a category names on this platform.
553        block.insert("channel_id".to_owned(), json!(category));
554    }
555    if let Some(url) = &notification.url {
556        block.insert("click_action".to_owned(), json!(url));
557    }
558    if let Some(icon) = notification.icon.as_deref().filter(|icon| !is_url(icon)) {
559        block.insert("icon".to_owned(), json!(icon));
560    }
561    if let Some(loc) = &notification.loc {
562        // `*_loc_args` are the substitutions for a `*_loc_key`, so each list
563        // is nested inside its key's arm: args without a key substitute into
564        // nothing and FCM defines no meaning for them.
565        if let Some(key) = &loc.title_loc_key {
566            block.insert("title_loc_key".to_owned(), json!(key));
567            if !loc.title_loc_args.is_empty() {
568                block.insert("title_loc_args".to_owned(), json!(loc.title_loc_args));
569            }
570        }
571        if let Some(key) = &loc.body_loc_key {
572            block.insert("body_loc_key".to_owned(), json!(key));
573            if !loc.body_loc_args.is_empty() {
574                block.insert("body_loc_args".to_owned(), json!(loc.body_loc_args));
575            }
576        }
577    }
578    block
579}
580
581/// The whole `{"message": {…}}` request body.
582///
583/// A silent notification is a **data-only** message: no `notification` block
584/// and no `android.notification` block, which is what stops Android from
585/// displaying anything and delivers the payload to the app instead.
586fn build_message(registration_token: &str, notification: &Notification) -> Value {
587    let mut message = Map::new();
588    message.insert("token".to_owned(), json!(registration_token));
589
590    if !notification.silent {
591        let mut visible = Map::new();
592        visible.insert("title".to_owned(), json!(notification.title));
593        visible.insert("body".to_owned(), json!(notification.body));
594        if let Some(image) = notification.icon.as_deref().filter(|icon| is_url(icon)) {
595            visible.insert("image".to_owned(), json!(image));
596        }
597        message.insert("notification".to_owned(), Value::Object(visible));
598    }
599
600    let mut android = Map::new();
601    android.insert(
602        "priority".to_owned(),
603        json!(priority_value(notification.priority)),
604    );
605    if let Some(ttl) = notification.ttl {
606        // FCM writes a TTL as a duration string, unlike the absolute epoch
607        // APNs takes. A sub-second TTL rounds up to one second through
608        // `ttl_secs` rather than truncating to `0s`, which would mean the
609        // opposite ("deliver now or drop").
610        android.insert("ttl".to_owned(), json!(format!("{}s", ttl_secs(ttl))));
611    }
612    if let Some(collapse_id) = &notification.collapse_id {
613        android.insert("collapse_key".to_owned(), json!(collapse_id));
614    }
615    if !notification.silent {
616        let block = build_android_notification(notification);
617        if !block.is_empty() {
618            android.insert("notification".to_owned(), Value::Object(block));
619        }
620    }
621    message.insert("android".to_owned(), Value::Object(android));
622
623    let data = build_data(notification);
624    if !data.is_empty() {
625        message.insert("data".to_owned(), Value::Object(data));
626    }
627
628    json!({ "message": Value::Object(message) })
629}
630
631/// What an FCM error body said, read **once** per failed send: the body is
632/// parsed a single time and both the code the mapping keys off and the text
633/// the log carries come out of that one parse.
634struct FcmFailure {
635    /// The `errorCode` of the `google.firebase.fcm.v1.FcmError` detail, which
636    /// is the only field in an FCM error body that identifies the failure
637    /// precisely.
638    code: Option<String>,
639    /// A human-readable detail: `errorCode: message` where there is a code,
640    /// else `error.status`, else `error.message`.
641    detail: String,
642}
643
644impl FcmFailure {
645    fn parse(body: &[u8]) -> Self {
646        let Ok(value) = serde_json::from_slice::<Value>(body) else {
647            return Self::opaque();
648        };
649        let Some(error) = value.get("error") else {
650            return Self::opaque();
651        };
652        let code = error
653            .get("details")
654            .and_then(Value::as_array)
655            .and_then(|details| {
656                details
657                    .iter()
658                    .find_map(|detail| detail.get("errorCode")?.as_str().map(str::to_owned))
659            });
660        let field = |name| error.get(name).and_then(Value::as_str);
661        // Both, where there are both: `errorCode` says *what* went wrong and
662        // `message` says *which value* did — "Invalid value at
663        // 'message.android.ttl'". Keeping only the code throws away the one
664        // part of the body that names the field, which is the part a reader
665        // of the log needs to fix it.
666        let detail = match (code.as_deref(), field("message")) {
667            (Some(code), Some(message)) => format!("{code}: {message}"),
668            (Some(code), None) => code.to_owned(),
669            (None, message) => field("status").or(message).unwrap_or(NO_DETAIL).to_owned(),
670        };
671        Self { code, detail }
672    }
673
674    /// A body that is not an FCM error at all — a proxy's HTML, an empty
675    /// 502, a load balancer's plain text.
676    fn opaque() -> Self {
677        Self {
678            code: None,
679            detail: NO_DETAIL.to_owned(),
680        }
681    }
682}
683
684/// What an error body that says nothing usable is reported as.
685const NO_DETAIL: &str = "no error detail";
686
687/// The `error`/`error_description` of an OAuth 2.0 error body (RFC 6749
688/// §5.2), which is what the token endpoint returns rather than an FCM error.
689/// The code is returned alongside the text because it, not the HTTP status,
690/// is what separates a permanently bad credential from a bad moment.
691fn oauth_error(body: &[u8]) -> (Option<String>, String) {
692    let Ok(value) = serde_json::from_slice::<Value>(body) else {
693        return (None, NO_DETAIL.to_owned());
694    };
695    let code = value
696        .get("error")
697        .and_then(Value::as_str)
698        .map(str::to_owned);
699    let description = value.get("error_description").and_then(Value::as_str);
700    let detail = match (code.as_deref(), description) {
701        (Some(code), Some(description)) => format!("{code}: {description}"),
702        (Some(code), None) => code.to_owned(),
703        (None, Some(description)) => description.to_owned(),
704        (None, None) => NO_DETAIL.to_owned(),
705    };
706    (code, detail)
707}
708
709/// Maps an FCM failure to the port's error.
710///
711/// The `errorCode` decides where there is one, because the HTTP status alone
712/// is ambiguous: a `404` is `UNREGISTERED` (prune the token) *or* a project
713/// that does not exist (a configuration bug), and `THIRD_PARTY_AUTH_ERROR` —
714/// the missing-APNs-credential case, which is a rejection and not an auth
715/// failure of ours — arrives as a `401`. Status is the fallback for a body
716/// with no recognisable detail, which is what a proxy or an outage returns.
717fn map_error(status: StatusCode, failure: &FcmFailure, retry_after: Option<Duration>) -> PushError {
718    let message = format!("fcm {}: {}", status.as_u16(), failure.detail);
719    match failure.code.as_deref() {
720        Some(UNREGISTERED) => PushError::Unregistered,
721        Some(INVALID_ARGUMENT | SENDER_ID_MISMATCH | THIRD_PARTY_AUTH_ERROR | APNS_AUTH_ERROR) => {
722            PushError::Rejected(message)
723        }
724        Some(QUOTA_EXCEEDED | UNAVAILABLE | INTERNAL) => {
725            PushError::transient_after(message, retry_after)
726        }
727        // UNSPECIFIED_ERROR, anything Google adds later, and a body with no
728        // recognisable detail at all: the status is what is left.
729        _ => map_by_status(status, &message, retry_after),
730    }
731}
732
733/// The fallback mapping, on HTTP status alone.
734///
735/// A `404` is **not** a prune signal here. `PushError::Unregistered` tells
736/// the caller to delete the device token, and only the explicit
737/// `UNREGISTERED` code means that. A bare `404` is what FCM answers for a
738/// project that does not exist — point `FCM_SERVICE_ACCOUNT_JSON` at a
739/// deleted or mistyped project and *every* send gets one — so treating it as
740/// a prune would delete a venture's whole device registry, one send at a
741/// time, from a configuration typo. Rejected is the honest answer: nothing
742/// about the recipient is known to be wrong.
743fn map_by_status(status: StatusCode, message: &str, retry_after: Option<Duration>) -> PushError {
744    if status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error() {
745        return PushError::transient_after(message.to_owned(), retry_after);
746    }
747    PushError::Rejected(message.to_owned())
748}
749
750#[async_trait]
751impl Push for Fcm {
752    async fn send(
753        &self,
754        to: &Recipient,
755        notification: &Notification,
756    ) -> Result<PushOutcome, PushError> {
757        // One transport per adapter: a venture that also speaks APNs or Web
758        // Push puts a `RoutingPush` in front (ADR 0015). This is decided
759        // *before* configuration is: an unconfigured FCM adapter still does
760        // not serve Web Push, and answering `NotConfigured` for a Web Push
761        // recipient would claim a transport it will never carry — a router
762        // reading that answer would stop looking for the adapter that does.
763        let Recipient::Fcm { registration_token } = to else {
764            return Err(PushError::unsupported_recipient(to));
765        };
766        let live = match &self.inner {
767            Inner::NotConfigured => return Ok(PushOutcome::NotConfigured),
768            Inner::Live(live) => live,
769        };
770        if registration_token.trim().is_empty() {
771            return Err(PushError::Rejected(
772                "malformed registration token".to_owned(),
773            ));
774        }
775
776        let body = Bytes::from(build_message(registration_token, notification).to_string());
777
778        // At most two attempts, and the second only after a 401 that says our
779        // bearer token — not the caller's request — is the problem.
780        let mut reauthenticated = false;
781        loop {
782            let token = live.access_token().await?;
783            let response = live.post_message(&token, body.clone()).await?;
784            let status = response.status();
785
786            if status.is_success() {
787                // `{"name": "projects/{id}/messages/{message_id}"}`.
788                let id = serde_json::from_slice::<Value>(response.body())
789                    .ok()
790                    .and_then(|value| value.get("name")?.as_str().map(str::to_owned));
791                return Ok(PushOutcome::Delivered { id });
792            }
793
794            // The error body is read once, here: both the code the mapping
795            // keys off and the text the log carries come out of that parse.
796            let failure = FcmFailure::parse(response.body());
797            // `THIRD_PARTY_AUTH_ERROR` is also a 401, and re-minting our own
798            // token cannot fix a missing APNs key in the Firebase project —
799            // so it is excluded here and rejected below.
800            let ours = status == StatusCode::UNAUTHORIZED
801                && !matches!(
802                    failure.code.as_deref(),
803                    Some(THIRD_PARTY_AUTH_ERROR | APNS_AUTH_ERROR)
804                );
805            if ours && !reauthenticated {
806                tracing::debug!("fcm rejected the bearer token; re-exchanging and retrying once");
807                live.token.invalidate(&());
808                reauthenticated = true;
809                continue;
810            }
811
812            let detail = &failure.detail;
813            let retry_after = retry_after(response.headers(), live.clock.as_ref());
814            if ours {
815                // A freshly exchanged token was refused too. Retryable, like
816                // the APNs expired-provider-token path: clock skew and a
817                // key mid-rotation both land here, and an outbox backing off
818                // is a better answer than discarding the notification.
819                return Err(PushError::transient_after(
820                    format!("fcm 401: {detail} (a freshly exchanged token was refused)"),
821                    retry_after,
822                ));
823            }
824            return Err(map_error(status, &failure, retry_after));
825        }
826    }
827}