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}