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}