Skip to main content

cratefield_runtime_cloudflare/
runtime.rs

1//! The `Cloudflare` runtime builder (issue #5).
2
3use std::collections::BTreeMap;
4use std::sync::Arc;
5use std::sync::atomic::{AtomicBool, Ordering};
6
7use cratefield_core::sigv4::Credentials;
8use cratefield_core::{
9    Auth, BoundedHttpClient, Captcha, Classifier, Clock, Config, CustomHostnames, Defer,
10    HarnessConfig, Mailer, Payments, Port, Ports, Push, Runtime, SidecarMounts, TextModel, Tracker,
11    UlidIdGen,
12};
13use worker::Env;
14
15use crate::config::EnvConfig;
16use crate::ports::{
17    D1Database, D1RateLimiter, DurableActors, FetchClient, KvStorePort, Limit, R2Blob, R2Presigner,
18    RateLimitPolicy, RateLimitPort, ServiceDispatcher, WorkersClock, vector_index_from_env,
19};
20
21pub(crate) fn warn_once(flag: &AtomicBool, message: &str) {
22    // One `swap`, not `load`-then-`store`: two threads can both read `false`
23    // in the gap between the two operations and both log, which is the one
24    // thing a "once" warning exists to prevent. The idiom `core`'s
25    // production-readiness acceptance flags already use (harness.rs:558).
26    if !flag.swap(true, Ordering::Relaxed) {
27        rt_log!(warn, "{message}");
28    }
29}
30
31/// Runtime logging: `worker::console_log!` on Workers (no tracing
32/// dispatcher can be installed there — see `tracing_setup`), `tracing!`
33/// natively.
34#[macro_export]
35macro_rules! rt_log {
36    (warn, $($arg:tt)*) => {{
37        #[cfg(target_arch = "wasm32")]
38        worker::console_log!("[warn] {}", format!($($arg)*));
39        #[cfg(not(target_arch = "wasm32"))]
40        tracing::warn!($($arg)*);
41    }};
42    (error, $($arg:tt)*) => {{
43        #[cfg(target_arch = "wasm32")]
44        worker::console_error!("[error] {}", format!($($arg)*));
45        #[cfg(not(target_arch = "wasm32"))]
46        tracing::error!($($arg)*);
47    }};
48}
49use rt_log;
50
51static WARNED_DB: AtomicBool = AtomicBool::new(false);
52static WARNED_KV: AtomicBool = AtomicBool::new(false);
53static WARNED_BLOB: AtomicBool = AtomicBool::new(false);
54static WARNED_BLOB_PRESIGN: AtomicBool = AtomicBool::new(false);
55static WARNED_VECTOR_INDEX: AtomicBool = AtomicBool::new(false);
56static WARNED_RATE_LIMIT: AtomicBool = AtomicBool::new(false);
57/// The missing-binding refusal (issue #562): logged once per isolate, the
58/// same `warn_once` discipline as the degraded-binding warning above.
59pub(crate) static WARNED_UNRESOLVED_LIMITER: AtomicBool = AtomicBool::new(false);
60static WARNED_SIGNER: AtomicBool = AtomicBool::new(false);
61static WARNED_SIDECAR: AtomicBool = AtomicBool::new(false);
62static WARNED_ACTORS: AtomicBool = AtomicBool::new(false);
63
64/// The Workers runtime. Binding names are static; `.mailer()`/`.captcha()`
65/// take adapter instances (`cratefield-adapter-resend`,
66/// `cratefield-adapter-turnstile`).
67///
68/// `Clone`, like the native runtime's `Native`, so a venture can hand one
69/// instance to `Harness::builder().runtime(..)` and keep the same one to
70/// serve with. Two separately-built instances are the shape of a
71/// silent bug: `Harness::build` validates every module's `requires()`
72/// against the ports of the instance it was given, and the one that actually
73/// serves is the other.
74#[derive(Clone)]
75pub struct Cloudflare {
76    db_binding: Option<&'static str>,
77    kv_binding: Option<&'static str>,
78    blob_binding: Option<&'static str>,
79    /// The four Worker var/secret names presigned R2 URLs read (issue #622):
80    /// account id, access key id, secret access key, bucket. Resolved in
81    /// `ports()` where the `Blob` port is assembled; `None` leaves
82    /// presigning off.
83    blob_presign: Option<(&'static str, &'static str, &'static str, &'static str)>,
84    /// The Vectorize index binding backing the `VectorIndex` port
85    /// (issue #561), resolved per event like KV and D1.
86    vector_index_binding: Option<&'static str>,
87    rate_limiter_binding: Option<&'static str>,
88    /// The Durable Object namespace binding backing the `Actor` port
89    /// (issue #583), resolved per event like KV and D1. One object per
90    /// `<kind>:<key>`, addressed by `DurableActors`.
91    actors_binding: Option<&'static str>,
92    /// The D1-backed per-key limiter (issue #538): the binding it reads its
93    /// counters from, plus the policy mapping a key to its budget. Fills
94    /// the same `RateLimiter` slot as `rate_limiter_binding`.
95    d1_rate_limiter: Option<(&'static str, RateLimitPolicy)>,
96    mailer: Option<Arc<dyn Mailer>>,
97    push: Option<Arc<dyn Push>>,
98    payments: Option<Arc<dyn Payments>>,
99    tracker: Option<Arc<dyn Tracker>>,
100    /// The `TextModel` port (issue #429): like `mailer` and `push`, the
101    /// adapter is built from the venture's vendor keys and passed in, not
102    /// resolved from a Worker binding.
103    text_model: Option<Arc<dyn TextModel>>,
104    /// The `Classifier` port (issue #456), the sibling of `text_model`:
105    /// passed in the same way. The Workers AI adapter reads the `env.AI`
106    /// binding itself, so the wiring a venture writes is identical to the
107    /// native runtime's.
108    classifier: Option<Arc<dyn Classifier>>,
109    /// The `CustomHostnames` port (issue #590): like `mailer`, the adapter
110    /// is built from the venture's vendor keys and passed in — the
111    /// Cloudflare for `SaaS` adapter (`cratefield-adapter-cloudflare-saas`)
112    /// is portable over the `HttpClient` port.
113    custom_hostnames: Option<Arc<dyn CustomHostnames>>,
114    captcha: Option<Arc<dyn Captcha>>,
115    auth: Option<Arc<dyn Auth>>,
116    /// Whether to assemble the `Auth` port from the environment.
117    auth_from_env: bool,
118    /// Whether to assemble the `Push` port from the environment
119    /// (issue #191). The `Env` only exists per event, so the assembly is
120    /// deferred to `ports()` and memoised for the isolate.
121    #[cfg(feature = "push")]
122    push_from_env: bool,
123    /// Shared through an `Arc` so a clone assembles the adapters once
124    /// between them, rather than parsing the `.p8`, the RSA key and the
125    /// VAPID scalar again per copy.
126    #[cfg(feature = "push")]
127    assembled_push: Arc<std::sync::OnceLock<(Arc<dyn Push>, cratefield_push_wiring::PushWiring)>>,
128}
129
130impl Default for Cloudflare {
131    fn default() -> Self {
132        Self::new()
133    }
134}
135
136impl Cloudflare {
137    pub fn new() -> Self {
138        Self {
139            db_binding: None,
140            kv_binding: None,
141            blob_binding: None,
142            blob_presign: None,
143            vector_index_binding: None,
144            rate_limiter_binding: None,
145            actors_binding: None,
146            d1_rate_limiter: None,
147            mailer: None,
148            push: None,
149            payments: None,
150            text_model: None,
151            classifier: None,
152            custom_hostnames: None,
153            tracker: None,
154            captcha: None,
155            auth: None,
156            auth_from_env: false,
157            #[cfg(feature = "push")]
158            push_from_env: false,
159            #[cfg(feature = "push")]
160            assembled_push: Arc::new(std::sync::OnceLock::new()),
161        }
162    }
163
164    #[must_use]
165    pub fn db(mut self, binding: &'static str) -> Self {
166        self.db_binding = Some(binding);
167        self
168    }
169
170    #[must_use]
171    pub fn kv(mut self, binding: &'static str) -> Self {
172        self.kv_binding = Some(binding);
173        self
174    }
175
176    /// The R2 bucket binding backing the `Blob` port.
177    #[must_use]
178    pub fn blob(mut self, binding: &'static str) -> Self {
179        self.blob_binding = Some(binding);
180        self
181    }
182
183    /// Presigned R2 URLs on the `Blob` port (issue #622): a `GET` a browser
184    /// can fetch directly from R2, skipping the Worker, and a `PUT` it can
185    /// upload to — both through R2's S3-compatible API, which the binding
186    /// alone cannot reach.
187    ///
188    /// Each argument names a Worker var or secret — the account id (the R2
189    /// endpoint's subdomain), an R2 API token's access key id and secret
190    /// access key, and the bucket (the endpoint's first path segment) — read
191    /// from the request's `Env` (secrets first, then vars). The `Env` exists
192    /// only per request, so the values cannot be resolved at compose time.
193    /// Scope the R2 token to the one bucket this port serves: a token that
194    /// reaches every bucket in the account reads every tenant's media.
195    ///
196    /// Without this call, or with any value missing or empty at request time,
197    /// presigning stays off and both presign methods answer
198    /// [`BlobError::Unsupported`](cratefield_core::BlobError::Unsupported).
199    #[must_use]
200    pub fn blob_presign(
201        mut self,
202        account_id: &'static str,
203        access_key_id: &'static str,
204        secret_access_key: &'static str,
205        bucket: &'static str,
206    ) -> Self {
207        self.blob_presign = Some((account_id, access_key_id, secret_access_key, bucket));
208        self
209    }
210
211    /// The Vectorize index binding backing the `VectorIndex` port (issue
212    /// #561): nearest-neighbour search, one namespace per tenant. A
213    /// deployment without the binding leaves the port unwired.
214    #[must_use]
215    pub fn vector_index(mut self, binding: &'static str) -> Self {
216        self.vector_index_binding = Some(binding);
217        self
218    }
219
220    #[must_use]
221    pub fn rate_limiter(mut self, binding: &'static str) -> Self {
222        self.rate_limiter_binding = Some(binding);
223        self
224    }
225
226    /// The Durable Object namespace binding backing the `Actor` port
227    /// (issue #583): per-key serialized state with transactional storage and
228    /// an alarm. The venture declares the `#[durable_object]` class and
229    /// forwards it to [`ActorDriver`](crate::ActorDriver); this names the
230    /// binding that class is bound under (`ACTORS` in the example). The
231    /// object is addressed by `<kind>:<key>`, so every call for one key lands
232    /// on the same object, which the runtime then serializes.
233    ///
234    /// The binding is resolved per event in `ports()`, where the harness
235    /// secret the frames are signed with is read from the same `Env`.
236    #[must_use]
237    pub fn actors(mut self, binding: &'static str) -> Self {
238        self.actors_binding = Some(binding);
239        self
240    }
241
242    /// The `RateLimiter` port over D1 (issue #538): a fixed window per key,
243    /// one atomic upsert per request ([`D1RateLimiter`]), so the budget can
244    /// vary per key — the Workers Rate Limiting binding above is one limit
245    /// per namespace. The policy maps a key to its [`Limit`]; `None` means
246    /// unlimited, allowed without a D1 round trip. Encode the plan in the
247    /// key (`plan:pro:ip:203.0.113.7`) and match on the prefix.
248    ///
249    /// Fills the same port slot as [`rate_limiter`](Self::rate_limiter);
250    /// where both are wired, this one wins. The counters table ships as
251    /// `RATE_LIMIT_COUNTERS_SQL`: add it to the venture's `migrations/`
252    /// directory.
253    #[must_use]
254    pub fn d1_rate_limiter(
255        mut self,
256        binding: &'static str,
257        policy: impl Fn(&str) -> Option<Limit> + Send + Sync + 'static,
258    ) -> Self {
259        self.d1_rate_limiter = Some((binding, Arc::new(policy)));
260        self
261    }
262
263    #[must_use]
264    pub fn mailer(mut self, mailer: impl Mailer + 'static) -> Self {
265        self.mailer = Some(Arc::new(mailer));
266        self
267    }
268
269    #[must_use]
270    pub fn mailer_arc(mut self, mailer: Arc<dyn Mailer>) -> Self {
271        self.mailer = Some(mailer);
272        self
273    }
274
275    /// The `Push` port. Like `mailer`, the adapter is built from the
276    /// venture's secrets (`cratefield-adapter-apns` from an APNs `.p8`) and
277    /// passed in, not resolved from a Worker binding.
278    #[must_use]
279    pub fn push(mut self, push: impl Push + 'static) -> Self {
280        self.push = Some(Arc::new(push));
281        self
282    }
283
284    #[must_use]
285    pub fn push_arc(mut self, push: Arc<dyn Push>) -> Self {
286        self.push = Some(push);
287        self
288    }
289
290    /// Assembles the `Push` port from the venture's environment instead of
291    /// taking an adapter (issue #191): one `RoutingPush` over whichever of
292    /// APNs, FCM and Web Push the deployment configured, built once per
293    /// isolate by `cratefield_push_wiring::build_push` — the same function
294    /// `fz push` and `fz doctor` call, so the three cannot drift apart on a
295    /// variable name.
296    ///
297    /// The port is then always provided, even with nothing configured: the
298    /// router answers `NotConfigured` for every recipient, exactly as an
299    /// unconfigured adapter does. `serve()` logs the resulting
300    /// [`PushWiring`](cratefield_push_wiring::PushWiring) report once at cold
301    /// start, and an explicit `.push(..)`/`.push_arc(..)` still wins.
302    ///
303    /// # This is a declaration, and `fz doctor` is what checks it
304    ///
305    /// `Harness::build` asks the runtime for its ports before any `Env`
306    /// exists — a Worker's bindings arrive with the first fetch — so the
307    /// build cannot know whether this deployment configured a transport.
308    /// Calling this therefore *declares* the Push port: a module that
309    /// requires push builds against it either way.
310    ///
311    /// `fz doctor` is the gate that used to be the build's. It runs where
312    /// the deployment's environment is known, and refuses a production
313    /// venture whose modules require push and whose environment routes no
314    /// transport at all (issue #191).
315    #[cfg(feature = "push")]
316    #[must_use]
317    pub fn push_from_env(mut self) -> Self {
318        self.push_from_env = true;
319        self
320    }
321
322    /// The environment-assembled `Push` port and its report, built once per
323    /// isolate.
324    ///
325    /// `None` when the venture did not ask for it — **and** when it passed
326    /// an explicit adapter, which wins: assembling a stack of adapters
327    /// nothing will serve with costs a `.p8` parse, an RSA parse and a VAPID
328    /// scalar per isolate, and then reports on transports the venture
329    /// deliberately overrode.
330    #[cfg(feature = "push")]
331    fn env_push(&self, env: &Env) -> Option<&(Arc<dyn Push>, cratefield_push_wiring::PushWiring)> {
332        if !self.push_from_env || self.push.is_some() {
333            return None;
334        }
335        Some(self.assembled_push.get_or_init(|| {
336            let clock: Arc<dyn Clock> = Arc::new(WorkersClock);
337            let http: Arc<dyn cratefield_core::HttpClient> = Arc::new(BoundedHttpClient::new(
338                Arc::new(FetchClient),
339                Arc::clone(&clock),
340            ));
341            cratefield_push_wiring::build_push(&EnvConfig(env.clone()), &http, &clock)
342        }))
343    }
344
345    /// Which push transports this deployment configured. `serve()` logs this
346    /// at cold start; nothing else needs it.
347    ///
348    /// `None` when the venture did not call
349    /// [`push_from_env`](Self::push_from_env), or passed an explicit adapter
350    /// that wins over it — there is nothing to report about an environment
351    /// nothing reads.
352    ///
353    /// Call it *after* `ports()`, as `serve()` does: `ports()` has then
354    /// already assembled and memoised the adapters, and this is the getter
355    /// it looks like.
356    #[cfg(feature = "push")]
357    #[must_use]
358    pub fn push_wiring(&self, env: &Env) -> Option<&cratefield_push_wiring::PushWiring> {
359        self.env_push(env).map(|(_, wiring)| wiring)
360    }
361
362    /// The `Payments` port. Like `mailer`, the adapter is built from the
363    /// venture's secrets (`cratefield-adapter-stripe` from the Stripe keys)
364    /// and passed in, not resolved from a Worker binding.
365    #[must_use]
366    pub fn payments(mut self, payments: impl Payments + 'static) -> Self {
367        self.payments = Some(Arc::new(payments));
368        self
369    }
370
371    #[must_use]
372    pub fn payments_arc(mut self, payments: Arc<dyn Payments>) -> Self {
373        self.payments = Some(payments);
374        self
375    }
376
377    /// The `Tracker` port. Like `mailer` and `payments`, the adapter is
378    /// built by the venture and passed in, not resolved from a Worker
379    /// binding.
380    ///
381    /// Unlike those adapters, it holds no credential: which tracker to file
382    /// into and under whose token are tenant data, supplied per call by the
383    /// module that files — see [`cratefield_core::Tracker`] for why that
384    /// differs from the Resend/Stripe shape.
385    #[must_use]
386    pub fn tracker(mut self, tracker: impl Tracker + 'static) -> Self {
387        self.tracker = Some(Arc::new(tracker));
388        self
389    }
390
391    /// `tracker` for an already-shared adapter.
392    #[must_use]
393    pub fn tracker_arc(mut self, tracker: Arc<dyn Tracker>) -> Self {
394        self.tracker = Some(tracker);
395        self
396    }
397
398    /// The `TextModel` port (issue #429): an adapter over the venture's
399    /// vendor of choice for each [`ModelTier`](cratefield_core::ModelTier),
400    /// or one `RoutingTextModel` over both tiers. Passed in, not resolved
401    /// from a Worker binding — there is no vendor-neutral binding to sniff
402    /// out of the environment.
403    #[must_use]
404    pub fn text_model(mut self, text_model: impl TextModel + 'static) -> Self {
405        self.text_model = Some(Arc::new(text_model));
406        self
407    }
408
409    #[must_use]
410    pub fn text_model_arc(mut self, text_model: Arc<dyn TextModel>) -> Self {
411        self.text_model = Some(text_model);
412        self
413    }
414
415    /// The `Classifier` port (issue #456): a typed, calibrated decision —
416    /// "which of these is it, and how sure are you" — passed in like
417    /// `text_model`. An adapter over the Workers AI `env.AI` binding is
418    /// still built by the venture and handed here, so there is no new
419    /// binding name to learn.
420    #[must_use]
421    pub fn classifier(mut self, classifier: impl Classifier + 'static) -> Self {
422        self.classifier = Some(Arc::new(classifier));
423        self
424    }
425
426    /// `classifier` for an already-shared adapter.
427    #[must_use]
428    pub fn classifier_arc(mut self, classifier: Arc<dyn Classifier>) -> Self {
429        self.classifier = Some(classifier);
430        self
431    }
432
433    /// The `CustomHostnames` port (issue #590): a customer's own hostname
434    /// served by a venture, passed in like `mailer`. The Cloudflare for
435    /// `SaaS` adapter is `cratefield-adapter-cloudflare-saas`, portable over
436    /// the `HttpClient` port, so this is the same wiring on either runtime.
437    #[must_use]
438    pub fn custom_hostnames(mut self, custom_hostnames: impl CustomHostnames + 'static) -> Self {
439        self.custom_hostnames = Some(Arc::new(custom_hostnames));
440        self
441    }
442
443    /// `custom_hostnames` for an already-shared adapter.
444    #[must_use]
445    pub fn custom_hostnames_arc(mut self, custom_hostnames: Arc<dyn CustomHostnames>) -> Self {
446        self.custom_hostnames = Some(custom_hostnames);
447        self
448    }
449
450    #[must_use]
451    pub fn captcha(mut self, captcha: impl Captcha + 'static) -> Self {
452        self.captcha = Some(Arc::new(captcha));
453        self
454    }
455
456    /// Wires who a request's credentials speak for (issue #153).
457    ///
458    /// Without one the deployment cannot identify a caller, and a module
459    /// that declares [`Port::Auth`] is refused composition rather than
460    /// mounted and left guessing.
461    #[must_use]
462    pub fn auth(mut self, auth: impl Auth + 'static) -> Self {
463        self.auth = Some(Arc::new(auth));
464        self
465    }
466
467    #[must_use]
468    pub fn auth_arc(mut self, auth: Arc<dyn Auth>) -> Self {
469        self.auth = Some(auth);
470        self
471    }
472
473    /// The clock, the HTTP client built on it, and the verifier built on
474    /// both — the verifier fetches the issuer's key set over HTTP and
475    /// dates the tokens by the clock, so it comes after them.
476    ///
477    /// Its own method because `ports` is at clippy's line limit.
478    fn clock_http_and_auth(&self, ports: &mut Ports) {
479        let clock: Arc<dyn Clock> = Arc::new(WorkersClock);
480        ports.clock = Some(Arc::clone(&clock));
481        let http: Arc<dyn cratefield_core::HttpClient> = Arc::new(BoundedHttpClient::new(
482            Arc::new(FetchClient),
483            Arc::clone(&clock),
484        ));
485        ports.http = Some(Arc::clone(&http));
486        if ports.auth.is_none() && self.auth_from_env {
487            ports.auth = Some(cratefield_auth_client::from_config(
488                ports.config.as_ref(),
489                http,
490                clock,
491            ));
492        }
493    }
494
495    /// The two `RateLimiter` sources: the Workers Rate Limiting binding
496    /// (one limit per namespace) and the D1-backed per-key limiter over a
497    /// D1 binding (issue #538), which wins where both are wired — the
498    /// per-key policy is what a venture with plans needs, the binding is
499    /// the cheaper default.
500    ///
501    /// Its own method because `ports` is at clippy's line limit.
502    fn rate_limiter_port(&self, env: &Env, ports: &mut Ports) {
503        if let Some(name) = self.rate_limiter_binding {
504            match env.rate_limiter(name) {
505                Ok(limiter) => ports.rate_limiter = Some(Arc::new(RateLimitPort(limiter))),
506                Err(err) => warn_once(
507                    &WARNED_RATE_LIMIT,
508                    &format!("Rate limit binding {name:?} not available: {err}"),
509                ),
510            }
511        }
512        if let Some((name, policy)) = &self.d1_rate_limiter {
513            match env.d1(name) {
514                Ok(db) => {
515                    ports.rate_limiter = Some(Arc::new(D1RateLimiter::new(db, Arc::clone(policy))));
516                }
517                Err(err) => warn_once(
518                    &WARNED_RATE_LIMIT,
519                    &format!("D1 rate limit binding {name:?} not available: {err}"),
520                ),
521            }
522        }
523    }
524
525    /// The `/v1/*` refusal when a limiter binding is **named** on this
526    /// runtime but resolved to nothing (issue #562): the detail of the
527    /// problem the serve path answers with, or `None` to serve.
528    ///
529    /// Composition named a binding, so the venture was written to be
530    /// throttled; a deployment where that binding is missing from the
531    /// uploaded Worker (or is the wrong type) used to degrade to no
532    /// limiter with only a warning — `ports.rate_limiter == None`, which
533    /// `production_readiness` reads as "not readiness" and refuses in
534    /// production, but which served **unlimited** everywhere a deployment
535    /// did not declare production. Failing closed here closes that gap in
536    /// every environment while leaving the readiness semantics alone: an
537    /// advertised-but-unresolved limiter is still not readiness.
538    #[must_use]
539    pub(crate) fn unresolved_limiter_refusal(&self, resolved: bool) -> Option<String> {
540        let mut named: Vec<&str> = Vec::new();
541        if let Some(name) = self.rate_limiter_binding {
542            named.push(name);
543        }
544        if let Some((name, _)) = &self.d1_rate_limiter {
545            named.push(name);
546        }
547        missing_limiter_detail(&named, resolved)
548    }
549
550    /// The `VectorIndex` port over a Vectorize index binding (issue #561).
551    ///
552    /// Its own method because `ports` is at clippy's line limit.
553    fn vector_index_port(&self, env: &Env, ports: &mut Ports) {
554        if let Some(name) = self.vector_index_binding {
555            match vector_index_from_env(env, name) {
556                Some(index) => ports.vector_index = Some(index),
557                None => warn_once(
558                    &WARNED_VECTOR_INDEX,
559                    &format!("Vectorize index binding {name:?} not available"),
560                ),
561            }
562        }
563    }
564
565    /// The `Actor` port over a Durable Object namespace binding (issue
566    /// #583): each `<kind>:<key>` names one object. Its own method because
567    /// `ports` is at clippy's line limit, and because it is only reachable
568    /// once the harness secret (the frame signing key) has been parsed.
569    fn actors_port(&self, env: &Env, config: &HarnessConfig, ports: &mut Ports) {
570        let Some(name) = self.actors_binding else {
571            return;
572        };
573        match env.durable_object(name) {
574            Ok(namespace) => {
575                ports.actors = Some(Arc::new(DurableActors::new(namespace, config)));
576            }
577            Err(err) => warn_once(
578                &WARNED_ACTORS,
579                &format!("Durable Object binding {name:?} not available: {err}"),
580            ),
581        }
582    }
583
584    /// The R2 presigning seam (issue #622): the four names
585    /// [`blob_presign`](Self::blob_presign) stored, resolved from this
586    /// request's `Env`.
587    ///
588    /// `None` — presigning off — when the venture never called `blob_presign`
589    /// (silently: it did not ask for it) or when any value is missing or empty
590    /// (logged once per isolate, by name, never by value).
591    fn r2_presigner(&self, env: &Env) -> Option<R2Presigner> {
592        let (account_id_name, access_key_name, secret_name, bucket_name) = self.blob_presign?;
593        let config = EnvConfig(env.clone());
594        let read =
595            |name: &'static str| Config::get(&config, name).filter(|value| !value.is_empty());
596        let (Some(account_id), Some(access_key_id), Some(secret_access_key), Some(bucket)) = (
597            read(account_id_name),
598            read(access_key_name),
599            read(secret_name),
600            read(bucket_name),
601        ) else {
602            warn_once(
603                &WARNED_BLOB_PRESIGN,
604                "blob_presign is wired but a value it named is missing or empty in this \
605                 deployment's environment: R2 presigning stays off, and the bytes are served \
606                 through the harness",
607            );
608            return None;
609        };
610        Some(R2Presigner::new(
611            account_id,
612            bucket,
613            Credentials::new(access_key_id, secret_access_key),
614            Arc::new(WorkersClock),
615        ))
616    }
617
618    /// Assembles the `Auth` port from `AUTH_ISSUER` and `AUTH_CLIENT_ID`
619    /// (issue #153), the way `push_from_env` assembles push.
620    ///
621    /// The port is provided either way. The `Env` exists per request on
622    /// Workers, so a runtime cannot know at compose time whether the
623    /// issuer is set, and refusing to provide the port would make every
624    /// such deployment fail to boot — including the ones whose tables are
625    /// all public. With the variables unset the port is a
626    /// `cratefield_core::Unconfigured`, which answers 503 to a request
627    /// that presents a credential and leaves an anonymous one anonymous.
628    #[must_use]
629    pub fn auth_from_env(mut self) -> Self {
630        self.auth_from_env = true;
631        self
632    }
633
634    #[must_use]
635    pub fn captcha_arc(mut self, captcha: Arc<dyn Captcha>) -> Self {
636        self.captcha = Some(captcha);
637        self
638    }
639
640    /// Resolves the per-request `Ports` from the actual bindings and the
641    /// event's `wait_until` context. Absent optional bindings leave the
642    /// port `None` (logged once per isolate); a missing `HARNESS_SECRET`
643    /// leaves `Signer` unset the same way.
644    pub fn ports(&self, env: &Env, defer: Arc<dyn Defer>) -> Ports {
645        let mut ports = Ports::with_config(Arc::new(EnvConfig(env.clone())));
646
647        if let Some(name) = self.db_binding {
648            match env.d1(name) {
649                Ok(db) => ports.db = Some(Arc::new(D1Database(db))),
650                Err(err) => warn_once(
651                    &WARNED_DB,
652                    &format!("D1 binding {name:?} not available: {err}"),
653                ),
654            }
655        }
656        if let Some(name) = self.blob_binding {
657            match env.bucket(name) {
658                Ok(bucket) => {
659                    ports.blob = Some(Arc::new(R2Blob::new(bucket, self.r2_presigner(env))));
660                }
661                Err(err) => warn_once(
662                    &WARNED_BLOB,
663                    &format!("R2 bucket binding {name:?} not available: {err}"),
664                ),
665            }
666        }
667        if let Some(name) = self.kv_binding {
668            match env.kv(name) {
669                Ok(kv) => ports.kv = Some(Arc::new(KvStorePort(kv))),
670                Err(err) => warn_once(
671                    &WARNED_KV,
672                    &format!("KV binding {name:?} not available: {err}"),
673                ),
674            }
675        }
676        self.rate_limiter_port(env, &mut ports);
677        self.vector_index_port(env, &mut ports);
678
679        match HarnessConfig::from_config(&EnvConfig(env.clone())) {
680            Ok(config) => {
681                // Key logged email pseudonyms from the harness secret (#135).
682                cratefield_core::set_log_pseudonym_key(config.harness_secret.as_bytes());
683                ports.signer = Some(Arc::new(config.signer()));
684                // The actor frames are signed with a key derived from the same
685                // secret, so the port is assembled here where the secret is.
686                self.actors_port(env, &config, &mut ports);
687            }
688            Err(err) => warn_once(&WARNED_SIGNER, &format!("Signer port not provided: {err}")),
689        }
690
691        // Sidecar bindings come from the mount table in config, not from the
692        // composition (ADR 0009), so the same artifact serves ventures with
693        // and without sidecars. A binding named by the table but absent from
694        // this deployment is left unresolved: `has()` then reports false and
695        // that one prefix answers 503, rather than the Worker failing.
696        match SidecarMounts::from_config(&EnvConfig(env.clone())) {
697            Ok(mounts) if !mounts.is_empty() => {
698                let mut bindings = BTreeMap::new();
699                for mount in mounts.iter() {
700                    match env.service(&mount.binding) {
701                        Ok(fetcher) => {
702                            bindings.insert(mount.binding.clone(), fetcher);
703                        }
704                        Err(err) => warn_once(
705                            &WARNED_SIDECAR,
706                            &format!(
707                                "service binding {:?} for sidecar {:?} not available: {err}",
708                                mount.binding, mount.name
709                            ),
710                        ),
711                    }
712                }
713                ports.dispatcher = Some(Arc::new(ServiceDispatcher::new(bindings)));
714            }
715            Ok(_) => {}
716            Err(errors) => {
717                for error in errors {
718                    warn_once(&WARNED_SIDECAR, &error);
719                }
720            }
721        }
722
723        self.clock_http_and_auth(&mut ports);
724        ports.id_gen = Some(Arc::new(UlidIdGen));
725        ports.defer = Some(defer);
726        ports.mailer.clone_from(&self.mailer);
727        ports.push.clone_from(&self.push);
728        ports.payments.clone_from(&self.payments);
729        ports.tracker.clone_from(&self.tracker);
730        ports.text_model.clone_from(&self.text_model);
731        ports.classifier.clone_from(&self.classifier);
732        ports.custom_hostnames.clone_from(&self.custom_hostnames);
733        ports.captcha.clone_from(&self.captcha);
734        // Only when set: `clock_http_and_auth` may already have assembled
735        // `ports.auth` from `AUTH_ISSUER` + `AUTH_CLIENT_ID` under
736        // `auth_from_env`, and `Option::clone_from(None)` would wipe it —
737        // leaving every `Port::Auth` route with `no-verifier`.
738        if let Some(auth) = &self.auth {
739            ports.auth = Some(Arc::clone(auth));
740        }
741
742        // An explicit adapter wins; otherwise assemble one from the
743        // environment when the venture asked for it (issue #191).
744        #[cfg(feature = "push")]
745        if ports.push.is_none()
746            && let Some((push, _)) = self.env_push(env)
747        {
748            ports.push = Some(Arc::clone(push));
749        }
750
751        ports
752    }
753}
754
755impl Runtime for Cloudflare {
756    /// The static set used by `Harness::build`. `Signer` is included: its
757    /// presence is validated at `ports()` time (and by `fz doctor`).
758    fn provides(&self) -> Vec<Port> {
759        let mut provided = vec![
760            Port::Signer,
761            Port::HttpClient,
762            Port::Clock,
763            Port::IdGen,
764            Port::Defer,
765        ];
766        if self.db_binding.is_some() {
767            provided.push(Port::Db);
768        }
769        if self.kv_binding.is_some() {
770            provided.push(Port::KeyValue);
771        }
772        if self.blob_binding.is_some() {
773            provided.push(Port::Blob);
774        }
775        if self.vector_index_binding.is_some() {
776            provided.push(Port::VectorIndex);
777        }
778        if self.rate_limiter_binding.is_some() || self.d1_rate_limiter.is_some() {
779            provided.push(Port::RateLimiter);
780        }
781        if self.actors_binding.is_some() {
782            provided.push(Port::Actor);
783        }
784        if self.mailer.is_some() {
785            provided.push(Port::Mailer);
786        }
787        // `push_from_env` provides the port whatever the environment holds:
788        // with nothing configured the router answers `NotConfigured` for
789        // every recipient, which is a provided port that sends nothing, not
790        // an absent one (issue #191).
791        //
792        // It cannot be otherwise here. `provides()` runs inside
793        // `Harness::build`, and a Worker's `Env` does not exist until the
794        // first fetch, so there is nothing to consult. That does move a
795        // check: a module requiring push used to fail the build on a venture
796        // with no push wiring, and now builds. `fz doctor` carries that
797        // refusal instead, where the deployment's environment is readable —
798        // see `push_from_env`.
799        #[cfg(feature = "push")]
800        let push_provided = self.push.is_some() || self.push_from_env;
801        #[cfg(not(feature = "push"))]
802        let push_provided = self.push.is_some();
803        if push_provided {
804            provided.push(Port::Push);
805        }
806        if self.payments.is_some() {
807            provided.push(Port::Payments);
808        }
809        if self.tracker.is_some() {
810            provided.push(Port::Tracker);
811        }
812        if self.text_model.is_some() {
813            provided.push(Port::TextModel);
814        }
815        if self.classifier.is_some() {
816            provided.push(Port::Classifier);
817        }
818        if self.custom_hostnames.is_some() {
819            provided.push(Port::CustomHostnames);
820        }
821        // Provided whatever the environment holds — see `auth_from_env`.
822        if self.auth.is_some() || self.auth_from_env {
823            provided.push(Port::Auth);
824        }
825        if self.captcha.is_some() {
826            provided.push(Port::Captcha);
827        }
828        provided
829    }
830
831    /// `Harness::build` gate (issue #133): a Turnstile adapter that is not
832    /// hostname-bound, or left fail-open, cannot verify a production
833    /// `HumanForm` route — the port is present but not usable, so report
834    /// it as not effectively configured. Adapters that do not report
835    /// (`binding() == None`) count as effective when present: presence is
836    /// all the runtime can know about them.
837    fn effectively_configured(&self, port: Port) -> bool {
838        match port {
839            // `is_none_or`, not `is_some_and`: the doc above and the
840            // `Captcha::binding` contract both say a non-reporting adapter
841            // counts as effective when present, and the code said the
842            // opposite — so a venture with any Captcha adapter that does
843            // not report could not boot in production (issue #143).
844            Port::Captcha => self.captcha.as_ref().is_some_and(|captcha| {
845                captcha
846                    .binding()
847                    .is_none_or(|binding| binding.hostname_bound && !binding.fail_open)
848            }),
849            _ => self.provides().contains(&port),
850        }
851    }
852}
853
854/// The fail-closed decision behind
855/// [`Cloudflare::unresolved_limiter_refusal`](crate::Cloudflare::unresolved_limiter_refusal),
856/// pure so it can be unit-tested off-wasm: bindings are named on the
857/// runtime, `resolved` says whether any of them actually handed a limiter
858/// over.
859/// Nothing named is the composition choosing to run without one — not a
860/// failure here, and readiness is the gate that judges it in production.
861fn missing_limiter_detail(named: &[&str], resolved: bool) -> Option<String> {
862    if named.is_empty() || resolved {
863        return None;
864    }
865    let quoted = named
866        .iter()
867        .map(|name| format!("{name:?}"))
868        .collect::<Vec<_>>()
869        .join(", ");
870    Some(format!(
871        "rate limit binding {quoted} is configured on this Worker but did not resolve at \
872         request time: /v1/* is refused rather than served without a budget — the binding is \
873         missing from this deployment or is the wrong type, and the readiness gate would \
874         refuse it in production all the same (issue #562)"
875    ))
876}
877
878#[cfg(test)]
879mod tests {
880    use super::*;
881
882    use cratefield_core::{
883        Answer, Calibration, ClassifierError, ClassifierProfile, Credential, Destination, Filed,
884        Harness, Question, TicketDraft, TicketState, TicketStatus, TrackerError, Venture,
885        VentureEnv,
886    };
887
888    /// Stands in for an adapter the venture passed itself. Answers the
889    /// cheapest thing that satisfies the trait; what these tests assert is
890    /// wiring, not adapter behaviour.
891    struct StubTracker;
892
893    #[async_trait::async_trait]
894    impl Tracker for StubTracker {
895        async fn file(
896            &self,
897            _dest: &Destination,
898            _cred: &Credential,
899            _draft: &TicketDraft,
900        ) -> Result<Filed, TrackerError> {
901            Err(TrackerError::NotConfigured)
902        }
903
904        async fn status(
905            &self,
906            _dest: &Destination,
907            _cred: &Credential,
908            external_id: &str,
909        ) -> Result<TicketStatus, TrackerError> {
910            Ok(TicketStatus {
911                external_id: external_id.to_owned(),
912                state: TicketState::Open,
913                url: None,
914            })
915        }
916    }
917
918    // `ports()` cannot be exercised here: it needs a `worker::Env`, which
919    // only exists per fetch on a Workers isolate. The clone into the bundle
920    // is the same one line `payments` does.
921
922    // ------------------------------------------- fail-closed limiter (#562)
923
924    #[test]
925    fn a_named_but_unresolved_limiter_binding_refuses() {
926        let detail = missing_limiter_detail(&["RATE_LIMITER"], false)
927            .expect("a named, unresolved binding refuses");
928        assert!(detail.contains("RATE_LIMITER"), "{detail}");
929        assert!(detail.contains("not resolve"), "{detail}");
930
931        // Both limiter sources named and neither resolved: both named.
932        let detail = missing_limiter_detail(&["RATE_LIMITER", "RATE_LIMIT_COUNTERS"], false)
933            .expect("neither source resolved");
934        assert!(detail.contains("RATE_LIMITER"), "{detail}");
935        assert!(detail.contains("RATE_LIMIT_COUNTERS"), "{detail}");
936    }
937
938    #[test]
939    fn an_unnamed_limiter_binding_is_not_a_refusal() {
940        // No binding named is the composition choosing to run without a
941        // limiter — readiness's business in production, not the serve
942        // path's in every environment.
943        assert_eq!(missing_limiter_detail(&[], false), None);
944    }
945
946    #[test]
947    fn a_resolved_limiter_binding_serves() {
948        assert_eq!(missing_limiter_detail(&["RATE_LIMITER"], true), None);
949        // Both sources named, the D1 one winning: resolved is resolved.
950        assert_eq!(
951            missing_limiter_detail(&["RATE_LIMITER", "RATE_LIMIT_COUNTERS"], true),
952            None
953        );
954    }
955
956    #[test]
957    fn the_refusal_follows_exactly_the_named_bindings() {
958        // Through the accessor the serve path uses: nothing configured on
959        // the runtime, serve; the Workers binding configured, refuse only
960        // when nothing resolved.
961        assert_eq!(Cloudflare::new().unresolved_limiter_refusal(false), None);
962        assert!(
963            Cloudflare::new()
964                .rate_limiter("RATE_LIMITER")
965                .unresolved_limiter_refusal(false)
966                .is_some()
967        );
968        assert_eq!(
969            Cloudflare::new()
970                .rate_limiter("RATE_LIMITER")
971                .unresolved_limiter_refusal(true),
972            None
973        );
974    }
975
976    #[test]
977    fn a_runtime_with_no_tracker_provides_no_tracker_port() {
978        assert!(!Cloudflare::new().provides().contains(&Port::Tracker));
979    }
980
981    #[test]
982    fn an_actors_binding_is_provided_by_name() {
983        // Like `.kv(..)` and `.vector_index(..)`: the builder stores a binding
984        // name, resolved per event in `ports()`, which needs a live `Env`.
985        assert!(
986            Cloudflare::new()
987                .actors("ACTORS")
988                .provides()
989                .contains(&Port::Actor)
990        );
991        assert!(!Cloudflare::new().provides().contains(&Port::Actor));
992    }
993
994    /// A public read published by a module that declares the limiter port
995    /// (issue #562) — the shape of every waitlist/auth composition.
996    struct DeclaresLimiter;
997
998    impl cratefield_core::Module for DeclaresLimiter {
999        fn name(&self) -> &'static str {
1000            "search"
1001        }
1002        fn version(&self) -> &'static str {
1003            "0.0.0-test"
1004        }
1005        fn requires(&self) -> &'static [Port] {
1006            &[]
1007        }
1008        fn optional(&self) -> &'static [Port] {
1009            &[Port::RateLimiter]
1010        }
1011        fn migrations(&self) -> cratefield_core::Migrations {
1012            cratefield_core::Migrations::default()
1013        }
1014        fn validate_config(
1015            &self,
1016            _: &dyn cratefield_core::Config,
1017        ) -> Result<(), cratefield_core::ConfigError> {
1018            Ok(())
1019        }
1020        fn surface(&self) -> cratefield_core::Surface {
1021            cratefield_core::Surface::new().action(
1022                cratefield_core::Action::get("search", "/search")
1023                    .audience(cratefield_core::Audience::Public),
1024            )
1025        }
1026        fn router(&self, _: cratefield_core::ModuleContext) -> axum::Router {
1027            axum::Router::new()
1028        }
1029    }
1030
1031    #[test]
1032    fn a_production_harness_without_a_limiter_binding_fails_to_build_naming_the_module() {
1033        let error = Harness::builder()
1034            .venture(
1035                Venture::new("venture", "test.example")
1036                    .cors_origins(["https://test.example"])
1037                    .env(VentureEnv::Production),
1038            )
1039            .module(DeclaresLimiter)
1040            .runtime(Cloudflare::new())
1041            .build()
1042            .expect_err("no limiter binding named: the build refuses");
1043        let message = error.problems.join("\n");
1044        assert!(message.contains("search"), "{message}");
1045        assert!(message.contains("GET /v1/search/search"), "{message}");
1046
1047        // The binding named: the build-time leg reads the runtime's own
1048        // answer, which is provisionally satisfied — resolution at request
1049        // time is the serve path's fail-closed rule.
1050        Harness::builder()
1051            .venture(
1052                Venture::new("venture", "test.example")
1053                    .cors_origins(["https://test.example"])
1054                    .env(VentureEnv::Production),
1055            )
1056            .module(DeclaresLimiter)
1057            .runtime(Cloudflare::new().rate_limiter("RATE_LIMITER"))
1058            .build()
1059            .expect("a named binding satisfies the build-time limiter leg");
1060    }
1061
1062    #[test]
1063    fn a_wired_tracker_is_provided_by_either_builder_form() {
1064        assert!(
1065            Cloudflare::new()
1066                .tracker(StubTracker)
1067                .provides()
1068                .contains(&Port::Tracker)
1069        );
1070        assert!(
1071            Cloudflare::new()
1072                .tracker_arc(Arc::new(StubTracker))
1073                .provides()
1074                .contains(&Port::Tracker)
1075        );
1076    }
1077
1078    /// Stands in for an adapter the venture passed itself — a
1079    /// `cratefield-adapter-workers-ai` or `cratefield-adapter-typesafe`,
1080    /// say. Answers the cheapest thing that satisfies the trait; what
1081    /// these tests assert is wiring, not adapter behaviour.
1082    struct StubClassifier;
1083
1084    #[async_trait::async_trait]
1085    impl Classifier for StubClassifier {
1086        fn profile(&self) -> ClassifierProfile {
1087            ClassifierProfile::new(Calibration::LanguageModel, 1_000)
1088        }
1089
1090        async fn ask(
1091            &self,
1092            _state: &str,
1093            _questions: &BTreeMap<String, Question>,
1094        ) -> Result<BTreeMap<String, Answer>, ClassifierError> {
1095            Err(ClassifierError::NotConfigured)
1096        }
1097    }
1098
1099    #[test]
1100    fn a_runtime_with_no_classifier_provides_no_classifier_port() {
1101        assert!(!Cloudflare::new().provides().contains(&Port::Classifier));
1102    }
1103
1104    #[test]
1105    fn a_vector_index_binding_is_provided_by_name() {
1106        // Like `.kv(..)`: the builder stores a binding name, resolved per
1107        // event in `ports()`, which needs a live `Env`.
1108        assert!(
1109            Cloudflare::new()
1110                .vector_index("VINDEX")
1111                .provides()
1112                .contains(&Port::VectorIndex)
1113        );
1114        assert!(!Cloudflare::new().provides().contains(&Port::VectorIndex));
1115    }
1116
1117    #[test]
1118    fn a_wired_classifier_is_provided_by_either_builder_form() {
1119        assert!(
1120            Cloudflare::new()
1121                .classifier(StubClassifier)
1122                .provides()
1123                .contains(&Port::Classifier)
1124        );
1125        assert!(
1126            Cloudflare::new()
1127                .classifier_arc(Arc::new(StubClassifier))
1128                .provides()
1129                .contains(&Port::Classifier)
1130        );
1131    }
1132
1133    /// A "warn once" flag two racing threads both read as unset is a "warn
1134    /// twice": `load`-then-`store` has a gap the other thread walks straight
1135    /// through, and the runtime's ports are assembled from several threads.
1136    /// The subscriber is installed before any `warn_once` call, because a
1137    /// callsite's interest is fixed at its first execution.
1138    #[test]
1139    fn a_warn_once_flag_is_claimed_by_exactly_one_thread() {
1140        use std::sync::atomic::{AtomicUsize, Ordering};
1141        use std::sync::{Arc, Barrier, OnceLock};
1142
1143        const MARKER: &str = "warn-once-race-marker";
1144
1145        struct Counter(Arc<AtomicUsize>);
1146
1147        impl tracing::Subscriber for Counter {
1148            fn enabled(&self, _metadata: &tracing::Metadata<'_>) -> bool {
1149                true
1150            }
1151
1152            fn new_span(&self, _attributes: &tracing::span::Attributes<'_>) -> tracing::span::Id {
1153                tracing::span::Id::from_u64(1)
1154            }
1155
1156            fn record(&self, _id: &tracing::span::Id, _values: &tracing::span::Record<'_>) {}
1157
1158            fn record_follows_from(&self, _id: &tracing::span::Id, _follows: &tracing::span::Id) {}
1159
1160            fn event(&self, event: &tracing::Event<'_>) {
1161                struct Marker<'a>(&'a str, &'a AtomicUsize);
1162                impl tracing::field::Visit for Marker<'_> {
1163                    fn record_debug(
1164                        &mut self,
1165                        field: &tracing::field::Field,
1166                        value: &dyn std::fmt::Debug,
1167                    ) {
1168                        // `rt_log!(warn, "{message}")` records the message as
1169                        // `format_args!`, so `Debug` is the rendered line.
1170                        if field.name() == "message" && format!("{value:?}").contains(self.0) {
1171                            self.1.fetch_add(1, Ordering::SeqCst);
1172                        }
1173                    }
1174                }
1175                event.record(&mut Marker(MARKER, &self.0));
1176            }
1177
1178            fn enter(&self, _id: &tracing::span::Id) {}
1179
1180            fn exit(&self, _id: &tracing::span::Id) {}
1181        }
1182
1183        static SEEN: OnceLock<Arc<AtomicUsize>> = OnceLock::new();
1184        let seen = SEEN.get_or_init(|| {
1185            let seen = Arc::new(AtomicUsize::new(0));
1186            let _ = tracing::subscriber::set_global_default(Counter(Arc::clone(&seen)));
1187            seen
1188        });
1189
1190        // 32 threads released from a barrier all read the same unset flag:
1191        // exactly one may log. Rounds rather than one shot, because the gap is
1192        // a window and a single pass can pass by luck.
1193        for _ in 0..200 {
1194            let flag = Arc::new(AtomicBool::new(false));
1195            let gate = Arc::new(Barrier::new(32));
1196            let threads: Vec<_> = (0..32)
1197                .map(|_| {
1198                    let gate = Arc::clone(&gate);
1199                    let flag = Arc::clone(&flag);
1200                    std::thread::spawn(move || {
1201                        gate.wait();
1202                        super::warn_once(&flag, MARKER);
1203                    })
1204                })
1205                .collect();
1206            for thread in threads {
1207                thread.join().expect("thread");
1208            }
1209            let logged = seen.swap(0, Ordering::SeqCst);
1210            assert_eq!(logged, 1, "{logged} threads logged a once-only warning");
1211        }
1212    }
1213}