Skip to main content

Crate cratefield_adapter_apns

Crate cratefield_adapter_apns 

Source
Expand description

cratefield-adapter-apns: the Push port over Apple Push Notification service (issues #104, #177). Uses the runtime’s HttpClient and Clock ports — no vendor SDK, no reqwest, no openssl — so the same adapter runs on Workers (worker::Fetch) and natively.

Recipients. It serves Recipient::Apns and returns PushError::unsupported_recipient for FCM and Web Push. A venture with more than one transport wires RoutingPush, which dispatches by variant (ADR 0015).

Authentication. APNs takes a provider JWT signed ES256 with the .p8 key from the Apple developer portal. Signing and caching live in cratefield_push_auth (issue #178), shared with the VAPID and Google signers: the token is minted once and reused, because Apple rejects regenerating it more than once per ~20 minutes (TooManyProviderTokenUpdates) and accepts it for up to 60, so the adapter caches it for JWT_TTL and re-mints past that. Signing is pure-Rust P-256 ECDSA with a deterministic RFC6979 nonce, so it needs no RNG on the isolate.

Degraded mode. Apns::not_configured reports PushOutcome::NotConfigured without any network call, the same contract Resend uses when its key is absent, so a venture with no APNs credentials still builds and runs. It answers that only for the transport it serves: an FCM or Web Push recipient is still PushError::unsupported_recipient, configured or not.

Verification. Signing and payload construction are unit-tested here, but the live path against Apple’s sandbox is needs-human (issue #104 acceptance): it needs a real .p8, a bundle id, and a device token, none of which live in the repo.

cratefield-adapter-apns — Push, to phones.

cratefield-adapter-apns on crates.io cratefield-adapter-apns documentation MIT

§cratefield-adapter-apns

The Push port over Apple Push Notification service (APNs), for the Cratefield harness (issue #104).

It talks HTTP/2 to api.push.apple.com (or the sandbox) through the runtime’s HttpClient port, so the one adapter runs unchanged on Cloudflare Workers and on the native runtime — no vendor SDK, no reqwest, no OpenSSL.

§Recipients

It serves Recipient::Apns and answers PushError::Rejected("unsupported recipient…") for Fcm and WebPush — including when it is not configured, which is a fact about the credentials and not about the transports it carries. A venture that speaks more than one transport puts cratefield_core::RoutingPush in front, which dispatches by variant (ADR 0015), so venture code holds one Arc<dyn Push>.

Notification is transport-neutral, so some fields are mapped and some are dropped, deliberately:

FieldAPNs
ttlapns-expiration, which is an absolute epoch: the adapter sends now + ttl, and 0 for a zero TTL (“deliver now or drop”). A sub-second TTL rounds up to one second, never down to 0
silentapns-push-type: background + aps.content-available alone, and priority 5 whatever the caller asked. Apple’s background-push contract is content-available with no alert, badge or sound, so badge, category and thread_id are dropped on a silent push — sending them makes APNs treat it as a user-visible notification and the silent wake never happens
badgeaps.badge (alert pushes only — see silent)
category / thread_idaps.category / aps.thread-id (alert pushes only — see silent)
urla top-level url in the payload, next to aps; APNs has no click target of its own. It wins over a url key in data; with no url set, data’s own url is left alone
locaps.alert.title-loc-key / title-loc-args / loc-key / loc-args, with title/body left as the fallback. *-loc-args are only emitted alongside their key — they are substitutions for it
collapse_idapns-collapse-id
priorityapns-priority 10 / 5
icondropped — an iOS notification takes its icon from the app bundle
datamerged at the top level, next to aps. The adapter’s own keys (aps, and url when set) are written last and win

§Authentication

APNs uses a provider JWT signed ES256 with the .p8 key from the Apple developer portal. Signing and the mint-once cache live in cratefield-push-auth, shared with the VAPID and Google signers. The adapter mints the token once and reuses it for 50 minutes (JWT_TTL): Apple rejects regenerating it more than once per ~20 minutes and accepts it for up to 60. Signing is pure-Rust P-256 ECDSA with a deterministic RFC6979 nonce, so it needs no RNG on a Workers isolate.

§Usage

ⓘ
use std::sync::Arc;
use cratefield_adapter_apns::{Apns, ApnsCredentials, ApnsHost};

// On Workers: read the secrets from `env`, and use the runtime's ports.
let http = Arc::new(cratefield_runtime_cloudflare::FetchClient);
let clock = Arc::new(cratefield_runtime_cloudflare::WorkersClock);

let push: Arc<dyn cratefield_core::Push> = match env.secret("APNS_KEY_P8").ok() {
    Some(p8) => Arc::new(Apns::new(http, clock, ApnsCredentials {
        key_p8_pem: p8.to_string(),
        key_id: env.secret("APNS_KEY_ID")?.to_string(),
        team_id: env.secret("APNS_TEAM_ID")?.to_string(),
        topic: env.secret("APNS_TOPIC")?.to_string(), // the app bundle id
        host: ApnsHost::parse(&env.secret("APNS_HOST")?.to_string())
            .unwrap_or(ApnsHost::Sandbox),
    })?),
    // No credentials set: reports NotConfigured, never touches the network.
    None => Arc::new(Apns::not_configured()),
};

// One transport: hand the adapter straight to the runtime. With more than
// one, wrap them: RoutingPush::new().apns(push).web_push(web).
let runtime = cratefield_runtime_cloudflare::Cloudflare::new().push_arc(push);

// Sending names the transport, not a bare string:
// push.send(&Recipient::apns(device_token), &notification).await?;

§Failure contract

send returns:

  • PushOutcome::Delivered { id } on 200 (the apns-id).
  • PushError::Unregistered on 410 — the device token is dead; delete it.
  • PushError::Transient { retry_after } on 429, 5xx, a transport error, or an expired provider token (the JWT cache is dropped so the next send re-signs) — retry, and not before retry_after where Apple sent a delta-seconds Retry-After.
  • PushError::Rejected(..) on any other 4xx (a bad payload, wrong topic), for a recipient this adapter does not serve, and for a device token that is not a bare token ("malformed device token" — anything but ASCII alphanumerics, - or _ would silently retarget the request path) — not retryable without a change.

§Verification

Signing and payload construction are unit-tested. The live path against Apple’s sandbox is needs-human: it requires a real .p8, bundle id, and device token, which do not live in the repo.


MIT. Built in the open for Cratefield, a Factory Zero venture.

Structs§

Apns
Push over POST https://{host}/3/device/{token}.
ApnsCredentials
The credentials a venture reads from its secrets to reach APNs.

Enums§

ApnsConfigError
Building an Apns adapter failed.
ApnsHost
Which APNs environment to target. A build signed with a development provisioning profile registers its token with the sandbox; a TestFlight/App Store build with production. Sending to the wrong host returns BadDeviceToken.

Constants§

JWT_TTL
How long a minted provider JWT is reused before it is re-signed. Apple accepts a token for 60 minutes and rejects regenerating one more than once per ~20; 50 minutes sits safely inside both bounds.