Skip to main content

cratefield_runtime_native/
runtime.rs

1//! The `Native` runtime builder (issue #19): the counterpart of
2//! `cratefield_runtime_cloudflare::Cloudflare` for a self-hosted binary.
3//!
4//! ```ignore
5//! # use std::sync::Arc;
6//! # fn db() -> Arc<dyn cratefield_core::Database> { unimplemented!() }
7//! # fn rate_limiter() -> Arc<dyn cratefield_core::RateLimiter> { unimplemented!() }
8//! # fn kv() -> Arc<dyn cratefield_core::KeyValue> { unimplemented!() }
9//! # fn mailer() -> Arc<dyn cratefield_core::Mailer> { unimplemented!() }
10//! use cratefield_runtime_native::Native;
11//!
12//! let runtime = Native::new()
13//!     .db_arc(db())                       // Postgres (adapter-postgres) or Sqlite
14//!     .rate_limiter_arc(rate_limiter())   // RedisRateLimiter, optional
15//!     .kv_arc(kv())                       // RedisKv, optional
16//!     .mailer_arc(mailer());              // Resend::from_env(..), as on Workers
17//! # let _runtime: Native = runtime;
18//! ```
19
20use std::sync::Arc;
21use std::sync::atomic::{AtomicBool, Ordering};
22
23use cratefield_core::{
24    Auth, Blob, BoundedHttpClient, Captcha, Classifier, Clock, Database, HarnessConfig, KeyValue,
25    Mailer, Payments, Port, Ports, Push, RateLimiter, Realtime, Runtime, TextModel, Tracker,
26    UlidIdGen,
27};
28
29use crate::config::EnvConfig;
30use crate::ports::{ReqwestClient, SpawnDefer, TokioClock};
31
32static WARNED_SIGNER: AtomicBool = AtomicBool::new(false);
33
34fn warn_once(flag: &AtomicBool, message: &str) {
35    if !flag.load(Ordering::Relaxed) {
36        flag.store(true, Ordering::Relaxed);
37        tracing::warn!("{message}");
38    }
39}
40
41/// The native runtime. Adapters are passed in as instances (the same
42/// pattern as the Cloudflare runtime's `.mailer(..)`/`.captcha(..)`):
43/// a Postgres or SQLite [`Database`](cratefield_core::Database) from the
44/// matching adapter crate, a [`RedisRateLimiter`](crate::RedisRateLimiter)
45/// and [`RedisKv`](crate::RedisKv) over a shared Redis connection
46/// manager, `Resend`/`Turnstile` unchanged from the Workers path.
47///
48/// Config — including `HARNESS_SECRET` for the `Signer` port — is read
49/// from `std::env` through the same [`cratefield_core::Config`] trait
50/// ([`EnvConfig`]), so module keys behave identically on both runtimes.
51#[derive(Clone, Default)]
52pub struct Native {
53    db: Option<Arc<dyn Database>>,
54    rate_limiter: Option<Arc<dyn RateLimiter>>,
55    kv: Option<Arc<dyn KeyValue>>,
56    blob: Option<Arc<dyn Blob>>,
57    push: Option<Arc<dyn Push>>,
58    payments: Option<Arc<dyn Payments>>,
59    tracker: Option<Arc<dyn Tracker>>,
60    realtime: Option<Arc<dyn Realtime>>,
61    /// The `TextModel` port (issue #429): like `mailer`, the adapter is
62    /// built from the venture's vendor keys and passed in — there is no
63    /// vendor-neutral binding to sniff out of the environment.
64    text_model: Option<Arc<dyn TextModel>>,
65    /// The `Classifier` port (issue #456), the sibling of `text_model`:
66    /// the adapter is passed in the same way. An adapter that reaches a
67    /// Workers AI binding still arrives here as an instance, so the two
68    /// runtimes wire this port identically.
69    classifier: Option<Arc<dyn Classifier>>,
70    mailer: Option<Arc<dyn Mailer>>,
71    captcha: Option<Arc<dyn Captcha>>,
72    auth: Option<Arc<dyn Auth>>,
73    /// Whether to assemble the `Auth` port from the environment.
74    auth_from_env: bool,
75    /// Whether to assemble the `Push` port from `std::env` (issue #191).
76    /// Built lazily in `ports()` and memoised for the process, so a venture
77    /// may call `push_from_env()` before a tokio runtime exists.
78    #[cfg(feature = "push")]
79    push_from_env: bool,
80    #[cfg(feature = "push")]
81    assembled_push: Arc<std::sync::OnceLock<(Arc<dyn Push>, cratefield_push_wiring::PushWiring)>>,
82}
83
84impl Native {
85    pub fn new() -> Self {
86        Self::default()
87    }
88
89    /// The `Database` port: `Postgres` (`cratefield-adapter-postgres`) or
90    /// `SqliteDatabase` (`cratefield-adapter-sqlite`) — any `Database`
91    /// implementation.
92    #[must_use]
93    pub fn db(mut self, db: impl Database + 'static) -> Self {
94        self.db = Some(Arc::new(db));
95        self
96    }
97
98    /// `db` for an already-shared database.
99    #[must_use]
100    pub fn db_arc(mut self, db: Arc<dyn Database>) -> Self {
101        self.db = Some(db);
102        self
103    }
104
105    /// The `RateLimiter` port: [`RedisRateLimiter`](crate::RedisRateLimiter) in this crate, or a
106    /// test fake.
107    #[must_use]
108    pub fn rate_limiter(mut self, limiter: impl RateLimiter + 'static) -> Self {
109        self.rate_limiter = Some(Arc::new(limiter));
110        self
111    }
112
113    /// `rate_limiter` for an already-shared limiter.
114    #[must_use]
115    pub fn rate_limiter_arc(mut self, limiter: Arc<dyn RateLimiter>) -> Self {
116        self.rate_limiter = Some(limiter);
117        self
118    }
119
120    /// The `KeyValue` port: [`RedisKv`](crate::RedisKv) in this crate, or a test fake.
121    #[must_use]
122    pub fn kv(mut self, kv: impl KeyValue + 'static) -> Self {
123        self.kv = Some(Arc::new(kv));
124        self
125    }
126
127    /// `kv` for an already-shared store.
128    #[must_use]
129    pub fn kv_arc(mut self, kv: Arc<dyn KeyValue>) -> Self {
130        self.kv = Some(kv);
131        self
132    }
133
134    /// The `Blob` port: a directory store ([`crate::DirBlob`]) or an
135    /// S3-compatible adapter.
136    #[must_use]
137    pub fn blob_arc(mut self, blob: Arc<dyn Blob>) -> Self {
138        self.blob = Some(blob);
139        self
140    }
141
142    /// The `Push` port: an APNs adapter (`cratefield-adapter-apns`) or a test
143    /// fake. Notifications reach a device over the runtime's `HttpClient`.
144    #[must_use]
145    pub fn push_arc(mut self, push: Arc<dyn Push>) -> Self {
146        self.push = Some(push);
147        self
148    }
149
150    /// Assembles the `Push` port from the process environment instead of
151    /// taking an adapter (issue #191): one `RoutingPush` over whichever of
152    /// APNs, FCM and Web Push the deployment configured, built by the same
153    /// `cratefield_push_wiring::build_push` the Workers runtime, `fz push`
154    /// and `fz doctor` call, so the four cannot drift apart on a variable
155    /// name.
156    ///
157    /// The port is then always provided, even with nothing configured: the
158    /// router answers `NotConfigured` for every recipient, exactly as an
159    /// unconfigured adapter does. `serve_on` logs the resulting
160    /// [`PushWiring`](cratefield_push_wiring::PushWiring) report once at cold
161    /// start, and an explicit `.push_arc(..)` still wins.
162    ///
163    /// # This is a declaration, and `fz doctor` is what checks it
164    ///
165    /// Declaring the port rather than gating it on what `std::env` holds at
166    /// build time is deliberate, and it keeps this runtime's answer the same
167    /// as the Workers one's: there a Worker's `Env` does not exist when
168    /// `Harness::build` runs, so a venture that built here and refused there
169    /// (or the reverse) is the divergence issue #143 was about.
170    ///
171    /// `fz doctor` is the gate that used to be the build's. It runs where
172    /// the deployment's environment is known, and refuses a production
173    /// venture whose modules require push and whose environment routes no
174    /// transport at all (issue #191).
175    #[cfg(feature = "push")]
176    #[must_use]
177    pub fn push_from_env(mut self) -> Self {
178        self.push_from_env = true;
179        self
180    }
181
182    /// The environment-assembled `Push` port and its report, built once per
183    /// process.
184    ///
185    /// `None` when the venture did not ask for it — **and** when it passed
186    /// an explicit adapter, which wins: assembling a stack of adapters
187    /// nothing will serve with costs a `.p8` parse, an RSA parse and a VAPID
188    /// scalar, and then reports on transports the venture deliberately
189    /// overrode.
190    #[cfg(feature = "push")]
191    fn env_push(&self) -> Option<&(Arc<dyn Push>, cratefield_push_wiring::PushWiring)> {
192        if !self.push_from_env || self.push.is_some() {
193            return None;
194        }
195        Some(self.assembled_push.get_or_init(|| {
196            let clock: Arc<dyn Clock> = Arc::new(TokioClock);
197            let http: Arc<dyn cratefield_core::HttpClient> = Arc::new(BoundedHttpClient::new(
198                Arc::new(ReqwestClient::new()),
199                Arc::clone(&clock),
200            ));
201            cratefield_push_wiring::build_push(&EnvConfig, &http, &clock)
202        }))
203    }
204
205    /// Which push transports this deployment configured. `serve_on` logs
206    /// this at cold start; nothing else needs it.
207    ///
208    /// `None` when the venture did not call
209    /// [`push_from_env`](Self::push_from_env), or passed an explicit adapter
210    /// that wins over it — there is nothing to report about an environment
211    /// nothing reads.
212    ///
213    /// Call it *after* `ports()`, as `serve_on` does: `ports()` has then
214    /// already assembled and memoised the adapters, and this is the getter
215    /// it looks like.
216    #[cfg(feature = "push")]
217    #[must_use]
218    pub fn push_wiring(&self) -> Option<&cratefield_push_wiring::PushWiring> {
219        self.env_push().map(|(_, wiring)| wiring)
220    }
221
222    /// The `Payments` port: a Stripe adapter (`cratefield-adapter-stripe`) or a
223    /// test fake, over the runtime's `HttpClient`.
224    #[must_use]
225    pub fn payments_arc(mut self, payments: Arc<dyn Payments>) -> Self {
226        self.payments = Some(payments);
227        self
228    }
229
230    /// The `Tracker` port: a [`cratefield_core::RoutingTracker`] over the
231    /// per-tracker adapters the venture configured, or a test fake.
232    ///
233    /// Unlike every other adapter on this harness, the adapter holds no
234    /// credential: which tracker to file into and under whose token are
235    /// tenant data, supplied per call by the module that files — see
236    /// [`cratefield_core::Tracker`] for why that differs from the
237    /// Resend/Stripe shape.
238    #[must_use]
239    pub fn tracker_arc(mut self, tracker: Arc<dyn Tracker>) -> Self {
240        self.tracker = Some(tracker);
241        self
242    }
243
244    /// The `Realtime` port: the in-process room registry
245    /// ([`crate::InProcessRealtime`]) built from the module's `RoomHandler`.
246    #[must_use]
247    pub fn realtime_arc(mut self, realtime: Arc<dyn Realtime>) -> Self {
248        self.realtime = Some(realtime);
249        self
250    }
251
252    /// The `TextModel` port (issue #429): an adapter over the venture's
253    /// vendor of choice for each [`ModelTier`](cratefield_core::ModelTier),
254    /// or one `RoutingTextModel` over both tiers. The same pattern as
255    /// `mailer`.
256    #[must_use]
257    pub fn text_model(mut self, text_model: impl TextModel + 'static) -> Self {
258        self.text_model = Some(Arc::new(text_model));
259        self
260    }
261
262    /// `text_model` for an already-shared adapter.
263    #[must_use]
264    pub fn text_model_arc(mut self, text_model: Arc<dyn TextModel>) -> Self {
265        self.text_model = Some(text_model);
266        self
267    }
268
269    /// The `Classifier` port (issue #456): a typed, calibrated decision —
270    /// "which of these is it, and how sure are you" — from a purpose-built
271    /// classifier, a Workers AI binding (on the Workers twin) or an LLM
272    /// behind the `TextModel` port. The same pattern as `text_model`.
273    #[must_use]
274    pub fn classifier(mut self, classifier: impl Classifier + 'static) -> Self {
275        self.classifier = Some(Arc::new(classifier));
276        self
277    }
278
279    /// `classifier` for an already-shared adapter.
280    #[must_use]
281    pub fn classifier_arc(mut self, classifier: Arc<dyn Classifier>) -> Self {
282        self.classifier = Some(classifier);
283        self
284    }
285
286    #[must_use]
287    pub fn mailer(mut self, mailer: impl Mailer + 'static) -> Self {
288        self.mailer = Some(Arc::new(mailer));
289        self
290    }
291
292    #[must_use]
293    pub fn mailer_arc(mut self, mailer: Arc<dyn Mailer>) -> Self {
294        self.mailer = Some(mailer);
295        self
296    }
297
298    #[must_use]
299    pub fn captcha(mut self, captcha: impl Captcha + 'static) -> Self {
300        self.captcha = Some(Arc::new(captcha));
301        self
302    }
303
304    #[must_use]
305    pub fn captcha_arc(mut self, captcha: Arc<dyn Captcha>) -> Self {
306        self.captcha = Some(captcha);
307        self
308    }
309
310    /// Wires who a request's credentials speak for (issue #153).
311    ///
312    /// Without one the deployment cannot identify a caller, and a module
313    /// that declares [`Port::Auth`] is refused composition rather than
314    /// mounted and left guessing.
315    #[must_use]
316    pub fn auth(mut self, auth: impl Auth + 'static) -> Self {
317        self.auth = Some(Arc::new(auth));
318        self
319    }
320
321    #[must_use]
322    pub fn auth_arc(mut self, auth: Arc<dyn Auth>) -> Self {
323        self.auth = Some(auth);
324        self
325    }
326
327    /// Assembles the `Auth` port from `AUTH_ISSUER` and `AUTH_CLIENT_ID`.
328    ///
329    /// The port is provided either way, matching the Cloudflare runtime:
330    /// a venture generated by `fz build` wires this whenever one of its
331    /// tables is not `public-read`, and the two runtimes answering
332    /// differently for the same venture would be a difference between
333    /// two deployments of one manifest.
334    #[must_use]
335    pub fn auth_from_env(mut self) -> Self {
336        self.auth_from_env = true;
337        self
338    }
339
340    /// Resolves the port bundle once per process from the builder and
341    /// `std::env`: config, `Signer` (from `HARNESS_SECRET` — warn once
342    /// and leave unset when missing, exactly like the Cloudflare
343    /// runtime), `HttpClient` over reqwest, `Clock` over the system
344    /// clock, `IdGen`, and `Defer` over `tokio::spawn`.
345    ///
346    /// Unlike the Workers runtime — which resolves bindings per event —
347    /// a native process builds one bundle and shares it: every adapter
348    /// here is an `Arc`, so `clone_ports` snapshots cheaply.
349    #[must_use]
350    pub fn ports(&self) -> Ports {
351        let config: Arc<dyn cratefield_core::Config> = Arc::new(EnvConfig);
352        let mut ports = Ports::with_config(Arc::clone(&config));
353
354        ports.db.clone_from(&self.db);
355        ports.rate_limiter.clone_from(&self.rate_limiter);
356        ports.kv.clone_from(&self.kv);
357        ports.blob.clone_from(&self.blob);
358        ports.push.clone_from(&self.push);
359        ports.payments.clone_from(&self.payments);
360        ports.tracker.clone_from(&self.tracker);
361        ports.realtime.clone_from(&self.realtime);
362        ports.text_model.clone_from(&self.text_model);
363        ports.classifier.clone_from(&self.classifier);
364        ports.mailer.clone_from(&self.mailer);
365        ports.captcha.clone_from(&self.captcha);
366        ports.auth.clone_from(&self.auth);
367
368        // An explicit adapter wins; otherwise assemble one from the
369        // environment when the venture asked for it (issue #191).
370        #[cfg(feature = "push")]
371        if ports.push.is_none()
372            && let Some((push, _)) = self.env_push()
373        {
374            ports.push = Some(Arc::clone(push));
375        }
376
377        match HarnessConfig::from_config(&*config) {
378            Ok(parsed) => {
379                // Pseudonymise logged emails with a key derived from the
380                // harness secret, not a bare (reversible) hash — issue #135.
381                cratefield_core::set_log_pseudonym_key(parsed.harness_secret.as_bytes());
382                ports.signer = Some(Arc::new(parsed.signer()));
383            }
384            Err(err) => warn_once(&WARNED_SIGNER, &format!("Signer port not provided: {err}")),
385        }
386
387        let clock: Arc<dyn Clock> = Arc::new(TokioClock);
388        ports.clock = Some(Arc::clone(&clock));
389        let http: Arc<dyn cratefield_core::HttpClient> = Arc::new(BoundedHttpClient::new(
390            Arc::new(ReqwestClient::new()),
391            Arc::clone(&clock),
392        ));
393        ports.http = Some(Arc::clone(&http));
394        // After the clock and the HTTP client, which the verifier needs
395        // to fetch the issuer's key set.
396        if ports.auth.is_none() && self.auth_from_env {
397            ports.auth = Some(cratefield_auth_client::from_config(
398                ports.config.as_ref(),
399                http,
400                clock,
401            ));
402        }
403        ports.id_gen = Some(Arc::new(UlidIdGen));
404        ports.defer = Some(Arc::new(SpawnDefer));
405        ports
406    }
407}
408
409impl Runtime for Native {
410    /// The static set used by `Harness::build`. `Signer` is included:
411    /// its presence is validated at `ports()` time (and by `fz doctor`),
412    /// mirroring the Cloudflare runtime.
413    fn provides(&self) -> Vec<Port> {
414        let mut provided = vec![
415            Port::Signer,
416            Port::HttpClient,
417            Port::Clock,
418            Port::IdGen,
419            Port::Defer,
420        ];
421        if self.db.is_some() {
422            provided.push(Port::Db);
423        }
424        // Provided whatever the environment holds — see `auth_from_env`.
425        if self.auth.is_some() || self.auth_from_env {
426            provided.push(Port::Auth);
427        }
428        if self.rate_limiter.is_some() {
429            provided.push(Port::RateLimiter);
430        }
431        if self.kv.is_some() {
432            provided.push(Port::KeyValue);
433        }
434        if self.blob.is_some() {
435            provided.push(Port::Blob);
436        }
437        // `push_from_env` provides the port whatever the environment holds:
438        // with nothing configured the router answers `NotConfigured` for
439        // every recipient, which is a provided port that sends nothing, not
440        // an absent one (issue #191).
441        //
442        // `std::env` is readable here, unlike on Workers — and consulting it
443        // is still the wrong move: `provides()` would then answer one thing
444        // in CI and another on the box, and a venture would build here and
445        // refuse to build on its Workers twin, which is the divergence
446        // issue #143 was about. `fz doctor` carries the refusal instead, on
447        // both runtimes, where the deployment's environment is known.
448        #[cfg(feature = "push")]
449        let push_provided = self.push.is_some() || self.push_from_env;
450        #[cfg(not(feature = "push"))]
451        let push_provided = self.push.is_some();
452        if push_provided {
453            provided.push(Port::Push);
454        }
455        if self.payments.is_some() {
456            provided.push(Port::Payments);
457        }
458        if self.tracker.is_some() {
459            provided.push(Port::Tracker);
460        }
461        if self.realtime.is_some() {
462            provided.push(Port::Realtime);
463        }
464        if self.text_model.is_some() {
465            provided.push(Port::TextModel);
466        }
467        if self.classifier.is_some() {
468            provided.push(Port::Classifier);
469        }
470        if self.mailer.is_some() {
471            provided.push(Port::Mailer);
472        }
473        if self.captcha.is_some() {
474            provided.push(Port::Captcha);
475        }
476        provided
477    }
478
479    /// The same production gate the Cloudflare runtime applies
480    /// (issue #133), which this runtime never did (issue #143): a
481    /// Turnstile adapter that is not hostname-bound, or left fail-open,
482    /// cannot protect a production `HumanForm` route, so the port is
483    /// present but not usable. Without this a self-hosted venture booted
484    /// past a check its Workers twin refuses. Adapters that do not report
485    /// (`binding() == None`) count as effective when present: presence is
486    /// all the runtime can know about them.
487    fn effectively_configured(&self, port: Port) -> bool {
488        match port {
489            Port::Captcha => self.captcha.as_ref().is_some_and(|captcha| {
490                captcha
491                    .binding()
492                    .is_none_or(|binding| binding.hostname_bound && !binding.fail_open)
493            }),
494            _ => self.provides().contains(&port),
495        }
496    }
497}
498
499/// Snapshots a [`Ports`] bundle by cloning the `Arc` of every [`Port`]
500/// and the config (core's `Ports` is not `Clone`; every field is a shared
501/// handle, so this is the whole job). `dispatcher` and `tenants` are not
502/// ports and are left `None`. Used to give the cron scheduler the same
503/// adapters the HTTP router serves with.
504pub(crate) fn clone_ports(ports: &Ports) -> Ports {
505    let mut snapshot = Ports::with_config(Arc::clone(&ports.config));
506    snapshot.db.clone_from(&ports.db);
507    snapshot.auth.clone_from(&ports.auth);
508    snapshot.mailer.clone_from(&ports.mailer);
509    snapshot.captcha.clone_from(&ports.captcha);
510    snapshot.rate_limiter.clone_from(&ports.rate_limiter);
511    snapshot.signer.clone_from(&ports.signer);
512    snapshot.kv.clone_from(&ports.kv);
513    snapshot.blob.clone_from(&ports.blob);
514    snapshot.push.clone_from(&ports.push);
515    snapshot.payments.clone_from(&ports.payments);
516    snapshot.tracker.clone_from(&ports.tracker);
517    snapshot.realtime.clone_from(&ports.realtime);
518    snapshot.text_model.clone_from(&ports.text_model);
519    snapshot.classifier.clone_from(&ports.classifier);
520    snapshot.http.clone_from(&ports.http);
521    snapshot.clock.clone_from(&ports.clock);
522    snapshot.id_gen.clone_from(&ports.id_gen);
523    snapshot.defer.clone_from(&ports.defer);
524    snapshot
525}
526
527#[cfg(test)]
528mod tests {
529    use super::clone_ports;
530    use cratefield_core::Port;
531
532    #[test]
533    fn clone_ports_carries_every_port() {
534        // Walks `Port::ALL`, so a port added to the enum and not copied
535        // here fails this — the way `auth` was dropped (issue #449).
536        let source = cratefield_testing::full_fake_ports();
537        assert!(
538            Port::ALL.iter().all(|port| source.has(*port)),
539            "the source bundle must wire every port for this to prove anything"
540        );
541        let snapshot = clone_ports(&source);
542        for port in Port::ALL {
543            assert!(snapshot.has(*port), "clone_ports drops {}", port.name());
544        }
545    }
546}