Skip to main content

cratefield_push_wiring/
lib.rs

1//! `cratefield-push-wiring` (issue #191): the one place that turns a
2//! venture's environment into the [`Push`] port.
3//!
4//! Three adapters serve three transports — `cratefield-adapter-apns`,
5//! `cratefield-adapter-fcm` and `cratefield-adapter-webpush` — and each is
6//! built from a handful of environment variables. Read those names in
7//! `serve()`, again in `fz push send` and a third time in `fz doctor` and
8//! they drift; the drift shows up in production as a notification that
9//! silently never sends. So the names live in exactly one table
10//! ([`PUSH_ENV`]), one function assembles the adapters from it
11//! ([`build_push`]), and everything else calls that function.
12//!
13//! # The partial case is the point
14//!
15//! A venture that meant to enable FCM and mistyped one variable must not
16//! boot into a state where every Android send answers
17//! [`PushOutcome::NotConfigured`](cratefield_core::PushOutcome) and nothing
18//! says why. [`build_push`] therefore returns a [`PushWiring`] report
19//! alongside the router, and the report distinguishes:
20//!
21//! - **Configured** — every variable the transport needs is set and the
22//!   adapter accepted them.
23//! - **Absent** — not one of its variables is set. The venture did not wire
24//!   that transport, which is a choice, not a defect.
25//! - **Partial** — some but not all. Somebody meant to wire it.
26//! - **Invalid** — all of them are set and the adapter refused them (a `.p8`
27//!   that is not a PKCS#8 key, an `APNS_HOST` that is neither host).
28//!
29//! Partial and Invalid are an **error in production** and a **warning
30//! everywhere else** ([`PushWiring::severity`]): a developer wiring APNs one
31//! variable at a time must still be able to boot.
32//!
33//! # Nothing that came out of the environment goes into the report
34//!
35//! The report is logged whole at cold start, so a single value copied into
36//! it reaches Workers Logs. An adapter's error is free to quote what it was
37//! handed — `VapidError::Subject` does — and `Vapid::new` validates the
38//! subject *before* the key, so `VAPID_SUBJECT` and `VAPID_PRIVATE_KEY`
39//! pasted the wrong way round (the mistake `VapidKeys`' own doc warns about)
40//! would have put the private key in the log. So an adapter's error string
41//! is never copied into [`TransportWiring::Invalid`]: every `reason` is a
42//! fixed phrase naming the variable and what was expected, and
43//! `the_report_never_prints_any_variables_value` holds every variable in the
44//! table to it.
45//!
46//! # Degraded, never broken
47//!
48//! A transport that is not configured is simply not routed: the
49//! [`RoutingPush`] answers `NotConfigured` for its recipients, which is the
50//! same contract each adapter's `not_configured()` constructor gives. With
51//! nothing configured at all the router is empty, every send is
52//! `NotConfigured`, and the Worker still boots.
53//!
54//! # Call sites
55//!
56//! - `serve()` on both runtimes, through `Cloudflare::push_from_env()` /
57//!   `Native::push_from_env()`, which build the router once per isolate or
58//!   process and log the report once at cold start.
59//! - `fz doctor`, through [`inspect_push`] — the same function with an
60//!   offline HTTP client, because the doctor never sends.
61//! - `fz push send` and `fz push doctor`, when the CLI lands (issue #184):
62//!   they call [`build_push`] and must not read any of these names
63//!   themselves. `crates/cli-acceptance/tests/push_env_guard.rs` fails the
64//!   build if they do.
65//!
66//! # The table is the documentation
67//!
68//! `docs/PUSH-ENV.md` is generated from [`PUSH_ENV`] by
69//! `cargo run -p cratefield-push-wiring --example push-env-doc`, and CI
70//! fails on drift — so the doc cannot describe variables the reader does not
71//! read.
72
73#![doc = include_str!("../README.md")]
74#![forbid(unsafe_code)]
75
76use std::fmt;
77use std::sync::Arc;
78
79use bytes::Bytes;
80use cratefield_adapter_apns::{Apns, ApnsConfigError, ApnsCredentials, ApnsHost};
81use cratefield_adapter_fcm::{Fcm, FcmConfigError};
82use cratefield_adapter_webpush::vapid::{VapidError, VapidKeys};
83use cratefield_adapter_webpush::{WebPush, WebPushConfigError};
84use cratefield_core::{
85    Clock, Config, HttpClient, HttpError, Platform, Push, RoutingPush, SystemClock, VentureEnv,
86};
87
88/// One push environment variable, as a **type**.
89///
90/// The table entry carries one of these and so does every lookup, so no
91/// variable is ever named a second time as a bare string literal: the name
92/// is spelled once, in [`PushKey::name`], and a lookup for a key the table
93/// does not carry does not compile.
94///
95/// That is the difference between a rename being a compile error and being a
96/// production incident. With a name-keyed lookup and an `unwrap_or_default`,
97/// renaming a table entry left the transport still reporting `Complete`
98/// while the credential handed to the adapter was `""` — which for
99/// `APNS_HOST` is a silent sandbox default in production and
100/// `BadDeviceToken` on every send.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
102pub enum PushKey {
103    /// The `.p8` provider key, whole.
104    ApnsKeyP8,
105    /// The `.p8` key's 10-character id.
106    ApnsKeyId,
107    /// The Apple team id.
108    ApnsTeamId,
109    /// The app's bundle id.
110    ApnsTopic,
111    /// `production` or `sandbox`.
112    ApnsHost,
113    /// The Google service-account JSON, whole.
114    FcmServiceAccountJson,
115    /// The VAPID private key.
116    VapidPrivateKey,
117    /// The VAPID contact URI.
118    VapidSubject,
119}
120
121impl PushKey {
122    /// The `SCREAMING_SNAKE` name, read through [`Config`] so a Workers
123    /// secret and a native environment variable are the same thing.
124    ///
125    /// The one place in the workspace a push variable is spelled out: this
126    /// match is exhaustive, so a new key without a name does not compile.
127    #[must_use]
128    pub const fn name(self) -> &'static str {
129        match self {
130            PushKey::ApnsKeyP8 => "APNS_KEY_P8",
131            PushKey::ApnsKeyId => "APNS_KEY_ID",
132            PushKey::ApnsTeamId => "APNS_TEAM_ID",
133            PushKey::ApnsTopic => "APNS_TOPIC",
134            PushKey::ApnsHost => "APNS_HOST",
135            PushKey::FcmServiceAccountJson => "FCM_SERVICE_ACCOUNT_JSON",
136            PushKey::VapidPrivateKey => "VAPID_PRIVATE_KEY",
137            PushKey::VapidSubject => "VAPID_SUBJECT",
138        }
139    }
140}
141
142/// One environment variable a push transport reads.
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub struct PushVar {
145    /// Which variable this is. Its [`name`](PushKey::name) is what is read
146    /// from the environment; nothing looks a variable up by that string.
147    pub key: PushKey,
148    /// The transport it belongs to.
149    pub transport: Platform,
150    /// Whether the transport cannot be built without it.
151    pub required: bool,
152    /// Whether the value is credential material. A `true` here means the
153    /// value must never reach a log — only this name and a verdict.
154    pub secret: bool,
155    /// What it is, for `docs/PUSH-ENV.md`.
156    pub purpose: &'static str,
157}
158
159impl PushVar {
160    /// The variable's name in the environment.
161    #[must_use]
162    pub const fn name(&self) -> &'static str {
163        self.key.name()
164    }
165}
166
167/// Every environment variable the push adapters read, in one table.
168///
169/// This is the single source of truth the acceptance test in
170/// `cli-acceptance` enforces: no other Rust source may name one of these
171/// keys in a string literal.
172pub const PUSH_ENV: &[PushVar] = &[
173    PushVar {
174        key: PushKey::ApnsKeyP8,
175        transport: Platform::Ios,
176        required: true,
177        secret: true,
178        purpose: "The `.p8` provider key from the Apple developer portal, PKCS#8 PEM, whole.",
179    },
180    PushVar {
181        key: PushKey::ApnsKeyId,
182        transport: Platform::Ios,
183        required: true,
184        secret: false,
185        purpose: "The key's 10-character id (the `.p8` filename suffix).",
186    },
187    PushVar {
188        key: PushKey::ApnsTeamId,
189        transport: Platform::Ios,
190        required: true,
191        secret: false,
192        purpose: "The 10-character Apple team id; the provider JWT's issuer.",
193    },
194    PushVar {
195        key: PushKey::ApnsTopic,
196        transport: Platform::Ios,
197        required: true,
198        secret: false,
199        purpose: "The app's bundle id, sent as `apns-topic`.",
200    },
201    PushVar {
202        key: PushKey::ApnsHost,
203        transport: Platform::Ios,
204        required: false,
205        secret: false,
206        purpose: "`production` or `sandbox`. Optional; defaults to `sandbox`, because a \
207                  development build's token is a sandbox token and sending it to production \
208                  fails every time.",
209    },
210    PushVar {
211        key: PushKey::FcmServiceAccountJson,
212        transport: Platform::Android,
213        required: true,
214        secret: true,
215        purpose: "The Google service-account JSON Firebase hands over, whole. `client_email`, \
216                  `private_key`, `project_id` and `token_uri` are read out of it, so there is \
217                  no second variable to keep in step.",
218    },
219    PushVar {
220        key: PushKey::VapidPrivateKey,
221        transport: Platform::Web,
222        required: true,
223        secret: true,
224        purpose: "The VAPID private key: a PKCS#8 PEM, or the bare 32-byte P-256 scalar \
225                  base64url. The public key is derived, never configured.",
226    },
227    PushVar {
228        key: PushKey::VapidSubject,
229        transport: Platform::Web,
230        required: true,
231        secret: false,
232        purpose: "RFC 8292 §2.1 `sub`: a `mailto:` or `https:` contact URI a push service can \
233                  reach the operator at.",
234    },
235];
236
237/// The transports, in report order.
238pub const TRANSPORTS: [Platform; 3] = [Platform::Ios, Platform::Android, Platform::Web];
239
240/// The [`RoutingPush`] builder name for a transport — `apns`, `fcm`,
241/// `web_push` — which is what a report line and a log say.
242#[must_use]
243pub fn transport_key(transport: Platform) -> &'static str {
244    match transport {
245        Platform::Ios => "apns",
246        Platform::Android => "fcm",
247        Platform::Web => "web_push",
248    }
249}
250
251/// The transport's name in prose (`APNs`, `FCM`, `Web Push`).
252#[must_use]
253pub fn transport_name(transport: Platform) -> &'static str {
254    match transport {
255        Platform::Ios => "APNs",
256        Platform::Android => "FCM",
257        Platform::Web => "Web Push",
258    }
259}
260
261/// The variables one transport reads.
262pub fn vars_for(transport: Platform) -> impl Iterator<Item = &'static PushVar> {
263    PUSH_ENV
264        .iter()
265        .filter(move |var| var.transport == transport)
266}
267
268/// How one transport was configured.
269#[derive(Debug, Clone, PartialEq, Eq, Default)]
270pub enum TransportWiring {
271    /// Not one of the transport's variables is set: the venture did not
272    /// wire it. Its recipients answer `NotConfigured`, which is the
273    /// documented outcome, not a fault.
274    #[default]
275    Absent,
276    /// Every required variable is set and the adapter accepted them.
277    Configured,
278    /// Some of the transport's variables are set and some are not. The
279    /// transport is **not** routed — every send would answer
280    /// `NotConfigured` — and somebody clearly meant it to work.
281    Partial {
282        /// The variables that are set, in table order.
283        present: Vec<&'static str>,
284        /// The required variables that are not, in table order.
285        missing: Vec<&'static str>,
286    },
287    /// Every variable is set and the adapter refused them.
288    Invalid {
289        /// Why the adapter refused the credentials: a fixed phrase naming
290        /// the variable and what was expected of it.
291        ///
292        /// Never the adapter's own error string, and so never a value out
293        /// of the environment — the whole report is logged at cold start
294        /// (see the crate documentation).
295        reason: String,
296    },
297}
298
299impl TransportWiring {
300    /// `configured`, `absent`, `partial` or `invalid` — the word a log line
301    /// and `docs/PUSH-ENV.md` use.
302    #[must_use]
303    pub fn verdict(&self) -> &'static str {
304        match self {
305            TransportWiring::Absent => "absent",
306            TransportWiring::Configured => "configured",
307            TransportWiring::Partial { .. } => "partial",
308            TransportWiring::Invalid { .. } => "invalid",
309        }
310    }
311
312    /// Whether this transport is routed. `false` for everything but
313    /// [`Configured`](TransportWiring::Configured), so an `Invalid`
314    /// transport degrades to `NotConfigured` rather than to a broken
315    /// adapter that fails on every send.
316    #[must_use]
317    pub fn is_routed(&self) -> bool {
318        matches!(self, TransportWiring::Configured)
319    }
320
321    /// Whether this is a misconfiguration rather than a decision.
322    #[must_use]
323    pub fn is_problem(&self) -> bool {
324        matches!(
325            self,
326            TransportWiring::Partial { .. } | TransportWiring::Invalid { .. }
327        )
328    }
329}
330
331/// How loudly a [`PushWiring`] report should be reported.
332#[derive(Debug, Clone, Copy, PartialEq, Eq)]
333pub enum WiringSeverity {
334    /// Every transport is either configured or deliberately absent.
335    Ok,
336    /// Something is half-wired, but this is not production: log it and
337    /// carry on, because wiring a transport one variable at a time is what
338    /// development looks like.
339    Warning,
340    /// Something is half-wired in production, where it means a whole
341    /// platform's notifications silently never send.
342    Error,
343}
344
345/// Which push transports a venture's environment configured, which it did
346/// not, and which it half did.
347///
348/// Never holds a value — only variable names and verdicts — so the whole
349/// report is safe to log.
350#[derive(Debug, Clone, PartialEq, Eq, Default)]
351pub struct PushWiring {
352    apns: TransportWiring,
353    fcm: TransportWiring,
354    web_push: TransportWiring,
355}
356
357impl PushWiring {
358    /// The verdict for one transport.
359    #[must_use]
360    pub fn get(&self, transport: Platform) -> &TransportWiring {
361        match transport {
362            Platform::Ios => &self.apns,
363            Platform::Android => &self.fcm,
364            Platform::Web => &self.web_push,
365        }
366    }
367
368    /// Every transport and its verdict, in report order.
369    pub fn iter(&self) -> impl Iterator<Item = (Platform, &TransportWiring)> {
370        TRANSPORTS
371            .into_iter()
372            .map(move |transport| (transport, self.get(transport)))
373    }
374
375    /// The one line logged at cold start: every transport, its verdict, and
376    /// — where it is half-wired — which variable names are set and which are
377    /// not. Names and verdicts only; never a value.
378    #[must_use]
379    pub fn summary(&self) -> String {
380        use fmt::Write as _;
381        let mut out = String::from("push wiring:");
382        for (transport, wiring) in self.iter() {
383            let _ = write!(out, " {}={}", transport_key(transport), wiring.verdict());
384            match wiring {
385                TransportWiring::Partial { present, missing } => {
386                    let _ = write!(
387                        out,
388                        " (set: {}; unset: {})",
389                        present.join(", "),
390                        missing.join(", ")
391                    );
392                }
393                TransportWiring::Invalid { reason } => {
394                    let _ = write!(out, " ({reason})");
395                }
396                TransportWiring::Absent | TransportWiring::Configured => {}
397            }
398        }
399        out
400    }
401
402    /// One message per misconfigured transport, each naming what is set,
403    /// what is not, and what it costs.
404    #[must_use]
405    pub fn problems(&self) -> Vec<String> {
406        let mut problems = Vec::new();
407        for (transport, wiring) in self.iter() {
408            let name = transport_name(transport);
409            match wiring {
410                TransportWiring::Partial { present, missing } => problems.push(format!(
411                    "{name} is partially configured: {} {} set; {} {} not. \
412                     {name} is left unrouted, so every {name} send reports NotConfigured and \
413                     nothing is delivered. Set the missing variables, or unset the rest to \
414                     say this transport is deliberately off.",
415                    present.join(", "),
416                    is_are(present.len()),
417                    missing.join(", "),
418                    is_are(missing.len()),
419                )),
420                TransportWiring::Invalid { reason } => problems.push(format!(
421                    "{name} is configured but its credentials were refused: {reason}. \
422                     {name} is left unrouted, so every {name} send reports NotConfigured."
423                )),
424                TransportWiring::Absent | TransportWiring::Configured => {}
425            }
426        }
427        problems
428    }
429
430    /// Whether any transport is actually routed — the question `fz doctor`
431    /// asks of a venture whose modules require the [`Push`] port: with
432    /// nothing routed every send answers `NotConfigured`.
433    #[must_use]
434    pub fn any_routed(&self) -> bool {
435        self.iter().any(|(_, wiring)| wiring.is_routed())
436    }
437
438    /// How loudly to report this. Partial and invalid configurations are an
439    /// error in production and a warning everywhere else.
440    // Asks each transport rather than building `problems()`: this runs on
441    // every cold start, and formatting three paragraphs to find out whether
442    // there are any is three allocations to answer a `bool`.
443    #[must_use]
444    pub fn severity(&self, env: VentureEnv) -> WiringSeverity {
445        if !self.iter().any(|(_, wiring)| wiring.is_problem()) {
446            WiringSeverity::Ok
447        } else if env == VentureEnv::Production {
448            WiringSeverity::Error
449        } else {
450            WiringSeverity::Warning
451        }
452    }
453
454    /// The report as a `Result`, for `fz doctor` and anything else that
455    /// fails a command rather than logging.
456    ///
457    /// # Errors
458    ///
459    /// Every problem, joined, when [`severity`](Self::severity) is
460    /// [`WiringSeverity::Error`] — that is, when something is half-wired in
461    /// production.
462    pub fn check(&self, env: VentureEnv) -> Result<(), String> {
463        match self.severity(env) {
464            WiringSeverity::Error => Err(self.problems().join("\n")),
465            WiringSeverity::Ok | WiringSeverity::Warning => Ok(()),
466        }
467    }
468}
469
470impl fmt::Display for PushWiring {
471    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
472        f.write_str(&self.summary())
473    }
474}
475
476/// `is` for one variable name, `are` for a list of them. A message an
477/// operator reads at 3am should not also make them parse the grammar.
478fn is_are(count: usize) -> &'static str {
479    if count == 1 { "is" } else { "are" }
480}
481
482/// What one transport's variables looked like in the environment.
483enum Survey {
484    Absent,
485    Partial {
486        present: Vec<&'static str>,
487        missing: Vec<&'static str>,
488    },
489    Complete(Values),
490}
491
492/// The values one transport's variables held, keyed by [`PushKey`] and not
493/// by name: the survey that read them and the builder that hands them to an
494/// adapter cannot disagree about which variable a value came from.
495struct Values(Vec<(PushKey, String)>);
496
497impl Values {
498    /// The value read for `key`, or `None` when the variable was unset —
499    /// which is only ever the case for a variable the table marks optional.
500    fn get(&self, key: PushKey) -> Option<&str> {
501        self.0
502            .iter()
503            .find(|(found, _)| *found == key)
504            .map(|(_, value)| value.as_str())
505    }
506}
507
508/// A value the survey reported present.
509///
510/// [`Survey::Complete`] means every required variable of the transport was
511/// read, so `None` cannot happen; it is still reported rather than defaulted
512/// to `""`, because handing an adapter an empty credential is the silent
513/// 403 this crate exists to prevent.
514fn required(values: &Values, key: PushKey) -> Result<String, TransportWiring> {
515    values.get(key).map(ToOwned::to_owned).ok_or_else(|| {
516        invalid(format!(
517            "{} was read from the environment and then lost (a cratefield-push-wiring bug)",
518            key.name()
519        ))
520    })
521}
522
523/// A value that is set and not blank, **trimmed**.
524///
525/// A variable present but empty is treated as unset: an empty Workers secret
526/// and an unset one mean the same thing to an operator, and treating `""` as
527/// configured would hand the adapter a credential it must then refuse.
528///
529/// The trim is not only for that test. A newline or a leading space picked
530/// up pasting a value into `wrangler secret put` is invisible, and an
531/// untrimmed `APNS_KEY_ID` reports `configured`, goes into the provider
532/// JWT's `kid`, and earns a 403 on every send — exactly the silent failure
533/// this crate exists to prevent. What is handed on is what was tested.
534fn value_of(config: &dyn Config, key: PushKey) -> Option<String> {
535    config
536        .get(key.name())
537        .map(|value| value.trim().to_owned())
538        .filter(|value| !value.is_empty())
539}
540
541fn survey(config: &dyn Config, transport: Platform) -> Survey {
542    let mut present = Vec::new();
543    let mut missing = Vec::new();
544    let mut values = Vec::new();
545    for var in vars_for(transport) {
546        match value_of(config, var.key) {
547            Some(value) => {
548                present.push(var.name());
549                values.push((var.key, value));
550            }
551            None if var.required => missing.push(var.name()),
552            None => {}
553        }
554    }
555    if present.is_empty() {
556        Survey::Absent
557    } else if missing.is_empty() {
558        Survey::Complete(Values(values))
559    } else {
560        Survey::Partial { present, missing }
561    }
562}
563
564/// Assembles the [`Push`] port from a venture's environment, and reports
565/// what it found.
566///
567/// The returned `Arc<dyn Push>` is always usable: a [`RoutingPush`] holding
568/// exactly the transports that are fully and validly configured. Everything
569/// else — absent, partial, invalid — is left unrouted, so its recipients get
570/// [`PushOutcome::NotConfigured`](cratefield_core::PushOutcome) and no send
571/// ever panics or reaches the network.
572///
573/// The [`PushWiring`] is the report to log once at cold start
574/// ([`PushWiring::summary`]) and to fail production on
575/// ([`PushWiring::check`]).
576///
577/// `http` and `clock` are the runtime's ports; the adapters speak HTTP
578/// through them and mint their provider tokens against them. Nothing here
579/// touches the network — the adapters are constructed, not used.
580#[must_use]
581pub fn build_push(
582    config: &dyn Config,
583    http: &Arc<dyn HttpClient>,
584    clock: &Arc<dyn Clock>,
585) -> (Arc<dyn Push>, PushWiring) {
586    let (apns_adapter, apns) = build_apns(config, http, clock);
587    let (fcm_adapter, fcm) = build_fcm(config, http, clock);
588    let (web_push_adapter, web_push) = build_web_push(config, http, clock);
589
590    let mut router = RoutingPush::new();
591    if let Some(adapter) = apns_adapter {
592        router = router.apns(adapter);
593    }
594    if let Some(adapter) = fcm_adapter {
595        router = router.fcm(adapter);
596    }
597    if let Some(adapter) = web_push_adapter {
598        router = router.web_push(adapter);
599    }
600
601    (
602        Arc::new(router),
603        PushWiring {
604            apns,
605            fcm,
606            web_push,
607        },
608    )
609}
610
611/// A transport that is not fully and validly configured contributes no
612/// adapter: `RoutingPush` then answers `NotConfigured` for its recipients,
613/// which is what an unrouted transport must do.
614type Built = (Option<Arc<dyn Push>>, TransportWiring);
615
616/// An [`TransportWiring::Invalid`] verdict.
617///
618/// Every caller passes a fixed phrase. Nothing that came out of the
619/// environment, and no adapter error string that might quote it, reaches a
620/// `reason` — the report is logged whole at cold start.
621fn invalid(reason: String) -> TransportWiring {
622    TransportWiring::Invalid { reason }
623}
624
625/// Why the APNs adapter refused the credentials, said without repeating any
626/// of them.
627fn apns_refusal(err: &ApnsConfigError) -> TransportWiring {
628    match err {
629        ApnsConfigError::Key(_) => invalid(format!(
630            "{} is not a PKCS#8 PEM P-256 private key — paste the `.p8` file whole, \
631             `-----BEGIN PRIVATE KEY-----` line included",
632            PushKey::ApnsKeyP8.name()
633        )),
634    }
635}
636
637/// The same for FCM. Each variant names the field of the service-account
638/// JSON at fault; the JSON itself is a secret and none of it is repeated.
639fn fcm_refusal(err: &FcmConfigError) -> TransportWiring {
640    let name = PushKey::FcmServiceAccountJson.name();
641    invalid(match err {
642        FcmConfigError::ServiceAccount(_) => format!(
643            "{name} is not the service-account JSON Firebase issues — it must parse as JSON \
644             and carry `project_id`, `private_key`, `client_email` and `token_uri`"
645        ),
646        FcmConfigError::Key(_) => {
647            format!("{name} carries a `private_key` that is not an RSA private key in PEM form")
648        }
649        FcmConfigError::ProjectId(_) => {
650            format!("{name} carries a `project_id` that is not a bare Firebase project id")
651        }
652        FcmConfigError::TokenUri(_) => {
653            format!("{name} carries a `token_uri` that is not an https URL")
654        }
655    })
656}
657
658/// The same for Web Push — and the one that matters most.
659///
660/// [`VapidError::Subject`]'s own `Display` quotes the subject it was given,
661/// and [`Vapid::new`](cratefield_adapter_webpush::vapid::Vapid::new)
662/// validates the subject *before* the key. A deployment that pasted
663/// `VAPID_SUBJECT` and `VAPID_PRIVATE_KEY` the wrong way round would
664/// therefore have put the private key into the report, and from there into
665/// the logs.
666fn web_push_refusal(err: &WebPushConfigError) -> TransportWiring {
667    invalid(match err {
668        WebPushConfigError::Vapid(VapidError::Key(_)) => format!(
669            "{} is neither a PKCS#8 PEM nor a base64url-encoded 32-byte P-256 scalar",
670            PushKey::VapidPrivateKey.name()
671        ),
672        WebPushConfigError::Vapid(VapidError::Subject(_)) => format!(
673            "{} is not a `mailto:` or `https:` contact URI (RFC 8292 §2.1). If it holds a \
674             key, it was swapped with {}",
675            PushKey::VapidSubject.name(),
676            PushKey::VapidPrivateKey.name()
677        ),
678        // Neither reaches `WebPush::new`: an endpoint belongs to a send and
679        // the record size is the adapter's own default. Named, not echoed.
680        WebPushConfigError::Vapid(VapidError::Endpoint(_)) | WebPushConfigError::Ece(_) => format!(
681            "the Web Push adapter refused the configuration built from {} and {}",
682            PushKey::VapidPrivateKey.name(),
683            PushKey::VapidSubject.name()
684        ),
685    })
686}
687
688fn build_apns(config: &dyn Config, http: &Arc<dyn HttpClient>, clock: &Arc<dyn Clock>) -> Built {
689    let values = match survey(config, Platform::Ios) {
690        Survey::Absent => return (None, TransportWiring::Absent),
691        Survey::Partial { present, missing } => {
692            return (None, TransportWiring::Partial { present, missing });
693        }
694        Survey::Complete(values) => values,
695    };
696
697    // Unset is the sandbox, deliberately: a development build's device token
698    // is a sandbox token, and the adapter's own README documents the same
699    // default. A value that is set and unparseable is a typo, not a default.
700    let host = match values.get(PushKey::ApnsHost) {
701        None => ApnsHost::Sandbox,
702        Some(raw) => match ApnsHost::parse(raw) {
703            Some(host) => host,
704            None => {
705                // The value is not echoed even though the table marks this
706                // variable non-secret: `secret: false` describes what the
707                // variable is for, not what an operator actually pasted into
708                // it, and a `.p8` body put in the wrong secret would land in
709                // the logs.
710                return (
711                    None,
712                    invalid(format!(
713                        "{} is neither `production` nor `sandbox`",
714                        PushKey::ApnsHost.name()
715                    )),
716                );
717            }
718        },
719    };
720
721    let creds = match apns_credentials(&values, host) {
722        Ok(creds) => creds,
723        Err(wiring) => return (None, wiring),
724    };
725    match Apns::new(Arc::clone(http), Arc::clone(clock), creds) {
726        Ok(apns) => (Some(Arc::new(apns)), TransportWiring::Configured),
727        Err(err) => (None, apns_refusal(&err)),
728    }
729}
730
731fn apns_credentials(values: &Values, host: ApnsHost) -> Result<ApnsCredentials, TransportWiring> {
732    Ok(ApnsCredentials {
733        key_p8_pem: required(values, PushKey::ApnsKeyP8)?,
734        key_id: required(values, PushKey::ApnsKeyId)?,
735        team_id: required(values, PushKey::ApnsTeamId)?,
736        topic: required(values, PushKey::ApnsTopic)?,
737        host,
738    })
739}
740
741fn build_fcm(config: &dyn Config, http: &Arc<dyn HttpClient>, clock: &Arc<dyn Clock>) -> Built {
742    let values = match survey(config, Platform::Android) {
743        Survey::Absent => return (None, TransportWiring::Absent),
744        Survey::Partial { present, missing } => {
745            return (None, TransportWiring::Partial { present, missing });
746        }
747        Survey::Complete(values) => values,
748    };
749
750    let json = match required(&values, PushKey::FcmServiceAccountJson) {
751        Ok(json) => json,
752        Err(wiring) => return (None, wiring),
753    };
754    match Fcm::from_service_account_json(Arc::clone(http), Arc::clone(clock), &json) {
755        Ok(fcm) => (Some(Arc::new(fcm)), TransportWiring::Configured),
756        Err(err) => (None, fcm_refusal(&err)),
757    }
758}
759
760fn build_web_push(
761    config: &dyn Config,
762    http: &Arc<dyn HttpClient>,
763    clock: &Arc<dyn Clock>,
764) -> Built {
765    let values = match survey(config, Platform::Web) {
766        Survey::Absent => return (None, TransportWiring::Absent),
767        Survey::Partial { present, missing } => {
768            return (None, TransportWiring::Partial { present, missing });
769        }
770        Survey::Complete(values) => values,
771    };
772
773    let keys = match vapid_keys(&values) {
774        Ok(keys) => keys,
775        Err(wiring) => return (None, wiring),
776    };
777    match WebPush::new(Arc::clone(http), Arc::clone(clock), keys) {
778        Ok(web_push) => (Some(Arc::new(web_push)), TransportWiring::Configured),
779        Err(err) => (None, web_push_refusal(&err)),
780    }
781}
782
783fn vapid_keys(values: &Values) -> Result<VapidKeys, TransportWiring> {
784    Ok(VapidKeys {
785        private_key: required(values, PushKey::VapidPrivateKey)?,
786        subject: required(values, PushKey::VapidSubject)?,
787    })
788}
789
790/// The same verdicts as [`build_push`], for a caller that has no HTTP
791/// client and needs none — `fz doctor`, which reports on an environment
792/// rather than sending through it.
793///
794/// It really is [`build_push`]: the adapters are constructed against an
795/// offline client that refuses every send, and then dropped with the
796/// router. Constructing them is what surfaces
797/// [`TransportWiring::Invalid`] — a `.p8` that is not a key is only
798/// discovered by parsing it — so the doctor sees exactly what the runtime
799/// will see, which is the whole point of there being one function.
800#[must_use]
801pub fn inspect_push(config: &dyn Config) -> PushWiring {
802    let http: Arc<dyn HttpClient> = Arc::new(OfflineHttpClient);
803    let clock: Arc<dyn Clock> = Arc::new(SystemClock);
804    let (_router, report) = build_push(config, &http, &clock);
805    report
806}
807
808/// The `applicationServerKey` a browser must pass to
809/// `pushManager.subscribe()`, base64url — or `None` when this venture has
810/// not configured Web Push (issue #183).
811///
812/// It lives here for the same reason [`build_push`] does: the VAPID
813/// variables have one reader. The **public** half is derived from the
814/// private key rather than configured, so a venture that serves this
815/// cannot serve a key that has drifted from the one its sends are signed
816/// with — which would be invisible until every browser subscription
817/// silently stopped working.
818///
819/// The key is public by definition: it is handed to every browser that
820/// subscribes. Nothing else from the environment comes back out.
821#[must_use]
822pub fn vapid_public_key(config: &dyn Config) -> Option<String> {
823    let Survey::Complete(values) = survey(config, Platform::Web) else {
824        return None;
825    };
826    let keys = vapid_keys(&values).ok()?;
827    let http: Arc<dyn HttpClient> = Arc::new(OfflineHttpClient);
828    let clock: Arc<dyn Clock> = Arc::new(SystemClock);
829    WebPush::new(http, clock, keys)
830        .ok()?
831        .public_key()
832        .map(str::to_owned)
833}
834
835/// An [`HttpClient`] that refuses every send, for [`inspect_push`]. The
836/// adapters store it and never call it; if one ever did, this is a loud
837/// refusal rather than a surprise network call from a diagnostic command.
838struct OfflineHttpClient;
839
840#[async_trait::async_trait]
841impl HttpClient for OfflineHttpClient {
842    async fn send(
843        &self,
844        _request: http::Request<Bytes>,
845    ) -> Result<http::Response<Bytes>, HttpError> {
846        Err(HttpError::Transport(
847            "this push adapter was built for inspection only and cannot send".to_owned(),
848        ))
849    }
850}
851
852/// `docs/PUSH-ENV.md`, generated from [`PUSH_ENV`] so the document
853/// cannot describe variables the reader does not read. Written and
854/// drift-checked by `cargo run -p cratefield-push-wiring --example
855/// push-env-doc`.
856#[must_use]
857pub fn notifications_doc() -> String {
858    use fmt::Write as _;
859
860    let mut out = String::new();
861    out.push_str("# The push environment\n\n");
862    out.push_str(
863        "*Generated reference. The hand-written notifications guide (push, in-app, \
864email and languages, with the per-platform client sections) is \
865`docs/NOTIFICATIONS.md`, issue #185, which links here rather than repeating this \
866table.*\n\n",
867    );
868    out.push_str(
869        "Every environment variable the push adapters read, generated from\n\
870         `cratefield-push-wiring`'s `PUSH_ENV` table by `cargo run -p\n\
871         cratefield-push-wiring --example push-env-doc` and checked in CI for\n\
872         drift (issue #191).\n\n\
873         One function reads these names — `build_push` — so no two callers can\n\
874         drift apart on a variable name. `serve()` reaches it through\n\
875         `push_from_env()` on either runtime, and `fz doctor` through\n\
876         `inspect_push`; `fz push` (issue #184) calls it directly when it lands.\n\
877         `crates/cli-acceptance/tests/push_env_guard.rs` fails the build if any\n\
878         other Rust source names one of them.\n\n\
879         Keys are read through the `Config` port, so on Workers a secret and a\n\
880         `[vars]` entry both work (a secret wins), and natively they are process\n\
881         environment variables. A variable set to the empty string counts as unset.\n\n",
882    );
883
884    for transport in TRANSPORTS {
885        let _ = writeln!(
886            out,
887            "## {} (`{}`)\n",
888            transport_name(transport),
889            transport_key(transport)
890        );
891        out.push_str("| Variable | Required | Secret | Purpose |\n");
892        out.push_str("|---|---|---|---|\n");
893        for var in vars_for(transport) {
894            let _ = writeln!(
895                out,
896                "| `{}` | {} | {} | {} |",
897                var.name(),
898                if var.required { "yes" } else { "no" },
899                if var.secret { "yes" } else { "no" },
900                var.purpose,
901            );
902        }
903        out.push('\n');
904    }
905
906    out.push_str(
907        "## Verdicts\n\n\
908         `build_push` returns a `PushWiring` report next to the router, and each\n\
909         runtime logs it once at cold start. Per transport:\n\n\
910         | Verdict | When | Consequence |\n\
911         |---|---|---|\n\
912         | `configured` | every required variable is set and the adapter accepted \
913         them | the transport is routed |\n\
914         | `absent` | not one of its variables is set | the transport is not routed; \
915         its recipients answer `NotConfigured`. A choice, not a defect |\n\
916         | `partial` | some of its variables are set and some are not | the transport \
917         is **not** routed. An error in production, a warning elsewhere |\n\
918         | `invalid` | all are set and the adapter refused them | the transport is \
919         **not** routed. An error in production, a warning elsewhere |\n\n\
920         Partial is the case this exists for: a venture that meant to enable FCM and\n\
921         mistyped one variable must not boot into a state where every Android send\n\
922         silently answers `NotConfigured`. In production that fails `fz doctor` and\n\
923         is logged at error; in development and staging it is a warning, because\n\
924         wiring a transport one variable at a time is what development looks like.\n\n\
925         The report holds variable **names** and verdicts only, never a value, so it\n\
926         is safe to log whole.\n",
927    );
928
929    out
930}
931
932#[cfg(test)]
933mod tests {
934    use super::*;
935    use cratefield_core::MapConfig;
936
937    #[test]
938    fn every_variable_belongs_to_exactly_one_transport_and_is_unique() {
939        let mut names: Vec<&str> = PUSH_ENV.iter().map(PushVar::name).collect();
940        names.sort_unstable();
941        let count = names.len();
942        names.dedup();
943        assert_eq!(names.len(), count, "a variable is listed twice");
944    }
945
946    #[test]
947    fn every_key_appears_in_the_table_exactly_once() {
948        // The typed lookup is only infallible if the table carries every
949        // key: a key the table does not hold could be asked for and never
950        // found. `PushKey::name` is exhaustive, so this pairs the two
951        // directions — and a key added to the enum but not to the table
952        // fails here rather than in production as an empty credential.
953        let keys: Vec<PushKey> = PUSH_ENV.iter().map(|var| var.key).collect();
954        for key in [
955            PushKey::ApnsKeyP8,
956            PushKey::ApnsKeyId,
957            PushKey::ApnsTeamId,
958            PushKey::ApnsTopic,
959            PushKey::ApnsHost,
960            PushKey::FcmServiceAccountJson,
961            PushKey::VapidPrivateKey,
962            PushKey::VapidSubject,
963        ] {
964            assert_eq!(
965                keys.iter().filter(|found| **found == key).count(),
966                1,
967                "{} is not in PUSH_ENV exactly once",
968                key.name()
969            );
970        }
971        assert_eq!(
972            keys.len(),
973            8,
974            "a key was added to the table, not to this test"
975        );
976    }
977
978    #[test]
979    fn a_value_is_trimmed_before_it_reaches_an_adapter() {
980        // A newline or a leading space picked up in a paste is invisible.
981        // Untrimmed, `APNS_KEY_ID` reports `configured`, goes into the
982        // provider JWT's `kid` and earns a 403 on every send.
983        let config = MapConfig::from_pairs([(
984            PushKey::ApnsKeyId.name().to_owned(),
985            " ABCDE12345\n".to_owned(),
986        )]);
987        assert_eq!(
988            value_of(&config, PushKey::ApnsKeyId).as_deref(),
989            Some("ABCDE12345")
990        );
991    }
992
993    #[test]
994    fn a_blank_value_is_unset() {
995        // An empty Workers secret and an unset one mean the same thing to
996        // an operator — and a value that is only whitespace is empty.
997        let config =
998            MapConfig::from_pairs([(PushKey::ApnsKeyId.name().to_owned(), "   \n".to_owned())]);
999        assert_eq!(value_of(&config, PushKey::ApnsKeyId), None);
1000    }
1001
1002    #[test]
1003    fn every_transport_has_at_least_one_required_variable() {
1004        // Without one, `survey` could never report `Absent` for it and the
1005        // router would try to build an adapter out of nothing.
1006        for transport in TRANSPORTS {
1007            assert!(
1008                vars_for(transport).any(|var| var.required),
1009                "{} has no required variable",
1010                transport_key(transport)
1011            );
1012        }
1013    }
1014
1015    #[test]
1016    fn severity_does_not_format_the_problems_to_find_out_whether_there_are_any() {
1017        // A cheap assertion of a cheap implementation: `severity` runs on
1018        // every cold start and must not allocate three paragraphs to answer
1019        // a bool. Kept honest by agreeing with `problems()` either way.
1020        for config in [
1021            MapConfig::from_pairs(Vec::<(String, String)>::new()),
1022            MapConfig::from_pairs([(
1023                PushKey::VapidSubject.name().to_owned(),
1024                "mailto:ops@example.test".to_owned(),
1025            )]),
1026        ] {
1027            let wiring = inspect_push(&config);
1028            assert_eq!(
1029                wiring.severity(VentureEnv::Production) == WiringSeverity::Ok,
1030                wiring.problems().is_empty(),
1031                "{}",
1032                wiring.summary()
1033            );
1034        }
1035    }
1036}