#![doc = include_str!("../README.md")]
#![forbid(unsafe_code)]
use std::fmt;
use std::sync::Arc;
use bytes::Bytes;
use cratefield_adapter_apns::{Apns, ApnsConfigError, ApnsCredentials, ApnsHost};
use cratefield_adapter_fcm::{Fcm, FcmConfigError};
use cratefield_adapter_webpush::vapid::{VapidError, VapidKeys};
use cratefield_adapter_webpush::{WebPush, WebPushConfigError};
use cratefield_core::{
Clock, Config, HttpClient, HttpError, Platform, Push, RoutingPush, SystemClock, VentureEnv,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PushKey {
ApnsKeyP8,
ApnsKeyId,
ApnsTeamId,
ApnsTopic,
ApnsHost,
FcmServiceAccountJson,
VapidPrivateKey,
VapidSubject,
}
impl PushKey {
#[must_use]
pub const fn name(self) -> &'static str {
match self {
PushKey::ApnsKeyP8 => "APNS_KEY_P8",
PushKey::ApnsKeyId => "APNS_KEY_ID",
PushKey::ApnsTeamId => "APNS_TEAM_ID",
PushKey::ApnsTopic => "APNS_TOPIC",
PushKey::ApnsHost => "APNS_HOST",
PushKey::FcmServiceAccountJson => "FCM_SERVICE_ACCOUNT_JSON",
PushKey::VapidPrivateKey => "VAPID_PRIVATE_KEY",
PushKey::VapidSubject => "VAPID_SUBJECT",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PushVar {
pub key: PushKey,
pub transport: Platform,
pub required: bool,
pub secret: bool,
pub purpose: &'static str,
}
impl PushVar {
#[must_use]
pub const fn name(&self) -> &'static str {
self.key.name()
}
}
pub const PUSH_ENV: &[PushVar] = &[
PushVar {
key: PushKey::ApnsKeyP8,
transport: Platform::Ios,
required: true,
secret: true,
purpose: "The `.p8` provider key from the Apple developer portal, PKCS#8 PEM, whole.",
},
PushVar {
key: PushKey::ApnsKeyId,
transport: Platform::Ios,
required: true,
secret: false,
purpose: "The key's 10-character id (the `.p8` filename suffix).",
},
PushVar {
key: PushKey::ApnsTeamId,
transport: Platform::Ios,
required: true,
secret: false,
purpose: "The 10-character Apple team id; the provider JWT's issuer.",
},
PushVar {
key: PushKey::ApnsTopic,
transport: Platform::Ios,
required: true,
secret: false,
purpose: "The app's bundle id, sent as `apns-topic`.",
},
PushVar {
key: PushKey::ApnsHost,
transport: Platform::Ios,
required: false,
secret: false,
purpose: "`production` or `sandbox`. Optional; defaults to `sandbox`, because a \
development build's token is a sandbox token and sending it to production \
fails every time.",
},
PushVar {
key: PushKey::FcmServiceAccountJson,
transport: Platform::Android,
required: true,
secret: true,
purpose: "The Google service-account JSON Firebase hands over, whole. `client_email`, \
`private_key`, `project_id` and `token_uri` are read out of it, so there is \
no second variable to keep in step.",
},
PushVar {
key: PushKey::VapidPrivateKey,
transport: Platform::Web,
required: true,
secret: true,
purpose: "The VAPID private key: a PKCS#8 PEM, or the bare 32-byte P-256 scalar \
base64url. The public key is derived, never configured.",
},
PushVar {
key: PushKey::VapidSubject,
transport: Platform::Web,
required: true,
secret: false,
purpose: "RFC 8292 §2.1 `sub`: a `mailto:` or `https:` contact URI a push service can \
reach the operator at.",
},
];
pub const TRANSPORTS: [Platform; 3] = [Platform::Ios, Platform::Android, Platform::Web];
#[must_use]
pub fn transport_key(transport: Platform) -> &'static str {
match transport {
Platform::Ios => "apns",
Platform::Android => "fcm",
Platform::Web => "web_push",
}
}
#[must_use]
pub fn transport_name(transport: Platform) -> &'static str {
match transport {
Platform::Ios => "APNs",
Platform::Android => "FCM",
Platform::Web => "Web Push",
}
}
pub fn vars_for(transport: Platform) -> impl Iterator<Item = &'static PushVar> {
PUSH_ENV
.iter()
.filter(move |var| var.transport == transport)
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum TransportWiring {
#[default]
Absent,
Configured,
Partial {
present: Vec<&'static str>,
missing: Vec<&'static str>,
},
Invalid {
reason: String,
},
}
impl TransportWiring {
#[must_use]
pub fn verdict(&self) -> &'static str {
match self {
TransportWiring::Absent => "absent",
TransportWiring::Configured => "configured",
TransportWiring::Partial { .. } => "partial",
TransportWiring::Invalid { .. } => "invalid",
}
}
#[must_use]
pub fn is_routed(&self) -> bool {
matches!(self, TransportWiring::Configured)
}
#[must_use]
pub fn is_problem(&self) -> bool {
matches!(
self,
TransportWiring::Partial { .. } | TransportWiring::Invalid { .. }
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WiringSeverity {
Ok,
Warning,
Error,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PushWiring {
apns: TransportWiring,
fcm: TransportWiring,
web_push: TransportWiring,
}
impl PushWiring {
#[must_use]
pub fn get(&self, transport: Platform) -> &TransportWiring {
match transport {
Platform::Ios => &self.apns,
Platform::Android => &self.fcm,
Platform::Web => &self.web_push,
}
}
pub fn iter(&self) -> impl Iterator<Item = (Platform, &TransportWiring)> {
TRANSPORTS
.into_iter()
.map(move |transport| (transport, self.get(transport)))
}
#[must_use]
pub fn summary(&self) -> String {
use fmt::Write as _;
let mut out = String::from("push wiring:");
for (transport, wiring) in self.iter() {
let _ = write!(out, " {}={}", transport_key(transport), wiring.verdict());
match wiring {
TransportWiring::Partial { present, missing } => {
let _ = write!(
out,
" (set: {}; unset: {})",
present.join(", "),
missing.join(", ")
);
}
TransportWiring::Invalid { reason } => {
let _ = write!(out, " ({reason})");
}
TransportWiring::Absent | TransportWiring::Configured => {}
}
}
out
}
#[must_use]
pub fn problems(&self) -> Vec<String> {
let mut problems = Vec::new();
for (transport, wiring) in self.iter() {
let name = transport_name(transport);
match wiring {
TransportWiring::Partial { present, missing } => problems.push(format!(
"{name} is partially configured: {} {} set; {} {} not. \
{name} is left unrouted, so every {name} send reports NotConfigured and \
nothing is delivered. Set the missing variables, or unset the rest to \
say this transport is deliberately off.",
present.join(", "),
is_are(present.len()),
missing.join(", "),
is_are(missing.len()),
)),
TransportWiring::Invalid { reason } => problems.push(format!(
"{name} is configured but its credentials were refused: {reason}. \
{name} is left unrouted, so every {name} send reports NotConfigured."
)),
TransportWiring::Absent | TransportWiring::Configured => {}
}
}
problems
}
#[must_use]
pub fn any_routed(&self) -> bool {
self.iter().any(|(_, wiring)| wiring.is_routed())
}
#[must_use]
pub fn severity(&self, env: VentureEnv) -> WiringSeverity {
if !self.iter().any(|(_, wiring)| wiring.is_problem()) {
WiringSeverity::Ok
} else if env == VentureEnv::Production {
WiringSeverity::Error
} else {
WiringSeverity::Warning
}
}
pub fn check(&self, env: VentureEnv) -> Result<(), String> {
match self.severity(env) {
WiringSeverity::Error => Err(self.problems().join("\n")),
WiringSeverity::Ok | WiringSeverity::Warning => Ok(()),
}
}
}
impl fmt::Display for PushWiring {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.summary())
}
}
fn is_are(count: usize) -> &'static str {
if count == 1 { "is" } else { "are" }
}
enum Survey {
Absent,
Partial {
present: Vec<&'static str>,
missing: Vec<&'static str>,
},
Complete(Values),
}
struct Values(Vec<(PushKey, String)>);
impl Values {
fn get(&self, key: PushKey) -> Option<&str> {
self.0
.iter()
.find(|(found, _)| *found == key)
.map(|(_, value)| value.as_str())
}
}
fn required(values: &Values, key: PushKey) -> Result<String, TransportWiring> {
values.get(key).map(ToOwned::to_owned).ok_or_else(|| {
invalid(format!(
"{} was read from the environment and then lost (a cratefield-push-wiring bug)",
key.name()
))
})
}
fn value_of(config: &dyn Config, key: PushKey) -> Option<String> {
config
.get(key.name())
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty())
}
fn survey(config: &dyn Config, transport: Platform) -> Survey {
let mut present = Vec::new();
let mut missing = Vec::new();
let mut values = Vec::new();
for var in vars_for(transport) {
match value_of(config, var.key) {
Some(value) => {
present.push(var.name());
values.push((var.key, value));
}
None if var.required => missing.push(var.name()),
None => {}
}
}
if present.is_empty() {
Survey::Absent
} else if missing.is_empty() {
Survey::Complete(Values(values))
} else {
Survey::Partial { present, missing }
}
}
#[must_use]
pub fn build_push(
config: &dyn Config,
http: &Arc<dyn HttpClient>,
clock: &Arc<dyn Clock>,
) -> (Arc<dyn Push>, PushWiring) {
let (apns_adapter, apns) = build_apns(config, http, clock);
let (fcm_adapter, fcm) = build_fcm(config, http, clock);
let (web_push_adapter, web_push) = build_web_push(config, http, clock);
let mut router = RoutingPush::new();
if let Some(adapter) = apns_adapter {
router = router.apns(adapter);
}
if let Some(adapter) = fcm_adapter {
router = router.fcm(adapter);
}
if let Some(adapter) = web_push_adapter {
router = router.web_push(adapter);
}
(
Arc::new(router),
PushWiring {
apns,
fcm,
web_push,
},
)
}
type Built = (Option<Arc<dyn Push>>, TransportWiring);
fn invalid(reason: String) -> TransportWiring {
TransportWiring::Invalid { reason }
}
fn apns_refusal(err: &ApnsConfigError) -> TransportWiring {
match err {
ApnsConfigError::Key(_) => invalid(format!(
"{} is not a PKCS#8 PEM P-256 private key — paste the `.p8` file whole, \
`-----BEGIN PRIVATE KEY-----` line included",
PushKey::ApnsKeyP8.name()
)),
}
}
fn fcm_refusal(err: &FcmConfigError) -> TransportWiring {
let name = PushKey::FcmServiceAccountJson.name();
invalid(match err {
FcmConfigError::ServiceAccount(_) => format!(
"{name} is not the service-account JSON Firebase issues — it must parse as JSON \
and carry `project_id`, `private_key`, `client_email` and `token_uri`"
),
FcmConfigError::Key(_) => {
format!("{name} carries a `private_key` that is not an RSA private key in PEM form")
}
FcmConfigError::ProjectId(_) => {
format!("{name} carries a `project_id` that is not a bare Firebase project id")
}
FcmConfigError::TokenUri(_) => {
format!("{name} carries a `token_uri` that is not an https URL")
}
})
}
fn web_push_refusal(err: &WebPushConfigError) -> TransportWiring {
invalid(match err {
WebPushConfigError::Vapid(VapidError::Key(_)) => format!(
"{} is neither a PKCS#8 PEM nor a base64url-encoded 32-byte P-256 scalar",
PushKey::VapidPrivateKey.name()
),
WebPushConfigError::Vapid(VapidError::Subject(_)) => format!(
"{} is not a `mailto:` or `https:` contact URI (RFC 8292 §2.1). If it holds a \
key, it was swapped with {}",
PushKey::VapidSubject.name(),
PushKey::VapidPrivateKey.name()
),
WebPushConfigError::Vapid(VapidError::Endpoint(_)) | WebPushConfigError::Ece(_) => format!(
"the Web Push adapter refused the configuration built from {} and {}",
PushKey::VapidPrivateKey.name(),
PushKey::VapidSubject.name()
),
})
}
fn build_apns(config: &dyn Config, http: &Arc<dyn HttpClient>, clock: &Arc<dyn Clock>) -> Built {
let values = match survey(config, Platform::Ios) {
Survey::Absent => return (None, TransportWiring::Absent),
Survey::Partial { present, missing } => {
return (None, TransportWiring::Partial { present, missing });
}
Survey::Complete(values) => values,
};
let host = match values.get(PushKey::ApnsHost) {
None => ApnsHost::Sandbox,
Some(raw) => match ApnsHost::parse(raw) {
Some(host) => host,
None => {
return (
None,
invalid(format!(
"{} is neither `production` nor `sandbox`",
PushKey::ApnsHost.name()
)),
);
}
},
};
let creds = match apns_credentials(&values, host) {
Ok(creds) => creds,
Err(wiring) => return (None, wiring),
};
match Apns::new(Arc::clone(http), Arc::clone(clock), creds) {
Ok(apns) => (Some(Arc::new(apns)), TransportWiring::Configured),
Err(err) => (None, apns_refusal(&err)),
}
}
fn apns_credentials(values: &Values, host: ApnsHost) -> Result<ApnsCredentials, TransportWiring> {
Ok(ApnsCredentials {
key_p8_pem: required(values, PushKey::ApnsKeyP8)?,
key_id: required(values, PushKey::ApnsKeyId)?,
team_id: required(values, PushKey::ApnsTeamId)?,
topic: required(values, PushKey::ApnsTopic)?,
host,
})
}
fn build_fcm(config: &dyn Config, http: &Arc<dyn HttpClient>, clock: &Arc<dyn Clock>) -> Built {
let values = match survey(config, Platform::Android) {
Survey::Absent => return (None, TransportWiring::Absent),
Survey::Partial { present, missing } => {
return (None, TransportWiring::Partial { present, missing });
}
Survey::Complete(values) => values,
};
let json = match required(&values, PushKey::FcmServiceAccountJson) {
Ok(json) => json,
Err(wiring) => return (None, wiring),
};
match Fcm::from_service_account_json(Arc::clone(http), Arc::clone(clock), &json) {
Ok(fcm) => (Some(Arc::new(fcm)), TransportWiring::Configured),
Err(err) => (None, fcm_refusal(&err)),
}
}
fn build_web_push(
config: &dyn Config,
http: &Arc<dyn HttpClient>,
clock: &Arc<dyn Clock>,
) -> Built {
let values = match survey(config, Platform::Web) {
Survey::Absent => return (None, TransportWiring::Absent),
Survey::Partial { present, missing } => {
return (None, TransportWiring::Partial { present, missing });
}
Survey::Complete(values) => values,
};
let keys = match vapid_keys(&values) {
Ok(keys) => keys,
Err(wiring) => return (None, wiring),
};
match WebPush::new(Arc::clone(http), Arc::clone(clock), keys) {
Ok(web_push) => (Some(Arc::new(web_push)), TransportWiring::Configured),
Err(err) => (None, web_push_refusal(&err)),
}
}
fn vapid_keys(values: &Values) -> Result<VapidKeys, TransportWiring> {
Ok(VapidKeys {
private_key: required(values, PushKey::VapidPrivateKey)?,
subject: required(values, PushKey::VapidSubject)?,
})
}
#[must_use]
pub fn inspect_push(config: &dyn Config) -> PushWiring {
let http: Arc<dyn HttpClient> = Arc::new(OfflineHttpClient);
let clock: Arc<dyn Clock> = Arc::new(SystemClock);
let (_router, report) = build_push(config, &http, &clock);
report
}
#[must_use]
pub fn vapid_public_key(config: &dyn Config) -> Option<String> {
let Survey::Complete(values) = survey(config, Platform::Web) else {
return None;
};
let keys = vapid_keys(&values).ok()?;
let http: Arc<dyn HttpClient> = Arc::new(OfflineHttpClient);
let clock: Arc<dyn Clock> = Arc::new(SystemClock);
WebPush::new(http, clock, keys)
.ok()?
.public_key()
.map(str::to_owned)
}
struct OfflineHttpClient;
#[async_trait::async_trait]
impl HttpClient for OfflineHttpClient {
async fn send(
&self,
_request: http::Request<Bytes>,
) -> Result<http::Response<Bytes>, HttpError> {
Err(HttpError::Transport(
"this push adapter was built for inspection only and cannot send".to_owned(),
))
}
}
#[must_use]
pub fn notifications_doc() -> String {
use fmt::Write as _;
let mut out = String::new();
out.push_str("# The push environment\n\n");
out.push_str(
"*Generated reference. The hand-written notifications guide (push, in-app, \
email and languages, with the per-platform client sections) is \
`docs/NOTIFICATIONS.md`, issue #185, which links here rather than repeating this \
table.*\n\n",
);
out.push_str(
"Every environment variable the push adapters read, generated from\n\
`cratefield-push-wiring`'s `PUSH_ENV` table by `cargo run -p\n\
cratefield-push-wiring --example push-env-doc` and checked in CI for\n\
drift (issue #191).\n\n\
One function reads these names — `build_push` — so no two callers can\n\
drift apart on a variable name. `serve()` reaches it through\n\
`push_from_env()` on either runtime, and `fz doctor` through\n\
`inspect_push`; `fz push` (issue #184) calls it directly when it lands.\n\
`crates/cli-acceptance/tests/push_env_guard.rs` fails the build if any\n\
other Rust source names one of them.\n\n\
Keys are read through the `Config` port, so on Workers a secret and a\n\
`[vars]` entry both work (a secret wins), and natively they are process\n\
environment variables. A variable set to the empty string counts as unset.\n\n",
);
for transport in TRANSPORTS {
let _ = writeln!(
out,
"## {} (`{}`)\n",
transport_name(transport),
transport_key(transport)
);
out.push_str("| Variable | Required | Secret | Purpose |\n");
out.push_str("|---|---|---|---|\n");
for var in vars_for(transport) {
let _ = writeln!(
out,
"| `{}` | {} | {} | {} |",
var.name(),
if var.required { "yes" } else { "no" },
if var.secret { "yes" } else { "no" },
var.purpose,
);
}
out.push('\n');
}
out.push_str(
"## Verdicts\n\n\
`build_push` returns a `PushWiring` report next to the router, and each\n\
runtime logs it once at cold start. Per transport:\n\n\
| Verdict | When | Consequence |\n\
|---|---|---|\n\
| `configured` | every required variable is set and the adapter accepted \
them | the transport is routed |\n\
| `absent` | not one of its variables is set | the transport is not routed; \
its recipients answer `NotConfigured`. A choice, not a defect |\n\
| `partial` | some of its variables are set and some are not | the transport \
is **not** routed. An error in production, a warning elsewhere |\n\
| `invalid` | all are set and the adapter refused them | the transport is \
**not** routed. An error in production, a warning elsewhere |\n\n\
Partial is the case this exists for: a venture that meant to enable FCM and\n\
mistyped one variable must not boot into a state where every Android send\n\
silently answers `NotConfigured`. In production that fails `fz doctor` and\n\
is logged at error; in development and staging it is a warning, because\n\
wiring a transport one variable at a time is what development looks like.\n\n\
The report holds variable **names** and verdicts only, never a value, so it\n\
is safe to log whole.\n",
);
out
}
#[cfg(test)]
mod tests {
use super::*;
use cratefield_core::MapConfig;
#[test]
fn every_variable_belongs_to_exactly_one_transport_and_is_unique() {
let mut names: Vec<&str> = PUSH_ENV.iter().map(PushVar::name).collect();
names.sort_unstable();
let count = names.len();
names.dedup();
assert_eq!(names.len(), count, "a variable is listed twice");
}
#[test]
fn every_key_appears_in_the_table_exactly_once() {
let keys: Vec<PushKey> = PUSH_ENV.iter().map(|var| var.key).collect();
for key in [
PushKey::ApnsKeyP8,
PushKey::ApnsKeyId,
PushKey::ApnsTeamId,
PushKey::ApnsTopic,
PushKey::ApnsHost,
PushKey::FcmServiceAccountJson,
PushKey::VapidPrivateKey,
PushKey::VapidSubject,
] {
assert_eq!(
keys.iter().filter(|found| **found == key).count(),
1,
"{} is not in PUSH_ENV exactly once",
key.name()
);
}
assert_eq!(
keys.len(),
8,
"a key was added to the table, not to this test"
);
}
#[test]
fn a_value_is_trimmed_before_it_reaches_an_adapter() {
let config = MapConfig::from_pairs([(
PushKey::ApnsKeyId.name().to_owned(),
" ABCDE12345\n".to_owned(),
)]);
assert_eq!(
value_of(&config, PushKey::ApnsKeyId).as_deref(),
Some("ABCDE12345")
);
}
#[test]
fn a_blank_value_is_unset() {
let config =
MapConfig::from_pairs([(PushKey::ApnsKeyId.name().to_owned(), " \n".to_owned())]);
assert_eq!(value_of(&config, PushKey::ApnsKeyId), None);
}
#[test]
fn every_transport_has_at_least_one_required_variable() {
for transport in TRANSPORTS {
assert!(
vars_for(transport).any(|var| var.required),
"{} has no required variable",
transport_key(transport)
);
}
}
#[test]
fn severity_does_not_format_the_problems_to_find_out_whether_there_are_any() {
for config in [
MapConfig::from_pairs(Vec::<(String, String)>::new()),
MapConfig::from_pairs([(
PushKey::VapidSubject.name().to_owned(),
"mailto:ops@example.test".to_owned(),
)]),
] {
let wiring = inspect_push(&config);
assert_eq!(
wiring.severity(VentureEnv::Production) == WiringSeverity::Ok,
wiring.problems().is_empty(),
"{}",
wiring.summary()
);
}
}
}