Skip to main content

cratefield_core/ports/
mod.rs

1//! Port traits (ADR 0002, architecture section 5). Modules see these traits
2//! and nothing else — never a Cloudflare binding, never a vendor client.
3//!
4//! All traits are `Send + Sync` and object-safe, used as `Arc<dyn Trait>`.
5//! Async methods use `async_trait` until native `async fn` in traits is
6//! ergonomic for trait objects.
7
8mod auth;
9mod blob;
10mod captcha;
11mod classifier;
12mod clock;
13mod database;
14mod defer;
15mod dispatcher;
16mod http;
17mod idgen;
18mod kv;
19mod mailer;
20mod payments;
21mod push;
22mod rate_limiter;
23mod realtime;
24pub(crate) mod signer;
25mod text_model;
26mod tracker;
27
28pub use auth::{Auth, AuthError, Caller, Subject, Unconfigured};
29pub use blob::{Blob, BlobError, BlobObject, MAX_BLOB_BYTES, ScopedBlob, check_blob_size};
30pub use captcha::{Captcha, CaptchaBinding, CaptchaError, Verdict};
31pub use classifier::{
32    Answer, AnswerValue, Calibration, Classifier, ClassifierError, ClassifierProfile,
33    DEFAULT_MAX_STATE_CHARS, Question, validate_questions,
34};
35pub use clock::{Clock, SystemClock, timeout};
36pub use database::{Database, DbError, Row, Rows, Statement, TryFromValue};
37pub use defer::{Defer, NoopDefer};
38pub use dispatcher::{DispatchError, Dispatcher};
39pub use http::{
40    BoundedHttpClient, DEFAULT_RESPONSE_TIMEOUT, HttpClient, HttpError, HttpPolicy,
41    MAX_CONCURRENT_REQUESTS, MAX_RESPONSE_BYTES, MAX_RESPONSE_TIMEOUT, declared_content_length,
42};
43pub use idgen::{IdGen, UlidIdGen};
44pub use kv::{KeyValue, KvError};
45pub use mailer::{MailError, Mailer, Message, SendOutcome};
46pub use payments::{
47    Charge, CheckoutRequest, CheckoutSession, ConnectAccountLink, ConnectAccountLinkRequest,
48    LineItem, Money, Payments, PaymentsError, Refund, RefundRequest, SubscriptionCheckoutRequest,
49    TransferCharge, WebhookEvent,
50};
51pub use push::{
52    LocKeys, Notification, Platform, Priority, Push, PushError, PushOutcome, Recipient,
53    RoutingPush, retry_after, ttl_secs,
54};
55pub use rate_limiter::{Decision, Quota, RateLimitError, RateLimiter};
56pub use realtime::{Member, Realtime, RealtimeError, RoomContext, RoomHandler};
57pub use signer::{Kid, MAX_KID_NAME, Payload, SignatureError, Signer};
58pub use text_model::{
59    Completion, DEFAULT_MAX_TOKENS, ModelTier, Prompt, Role, RoutingTextModel, TextModel,
60    TextModelError, Turn,
61};
62pub use tracker::{
63    Credential, Destination, Filed, RoutingTracker, Severity, TicketDraft, TicketState,
64    TicketStatus, Tracker, TrackerError,
65};
66
67use crate::config::Config;
68use crate::module::Module;
69use std::sync::Arc;
70use tracing::warn;
71
72/// Every port a module can declare in `requires()` / `optional()`
73/// (architecture section 4).
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
75pub enum Port {
76    Db,
77    Auth,
78    Mailer,
79    Captcha,
80    RateLimiter,
81    Signer,
82    KeyValue,
83    Blob,
84    Push,
85    Payments,
86    Tracker,
87    Realtime,
88    TextModel,
89    Classifier,
90    HttpClient,
91    Clock,
92    IdGen,
93    Defer,
94}
95
96/// Declares [`Port::ALL`] and, from the same list, a match that has to be
97/// exhaustive.
98///
99/// `ALL` is walked by [`Ports::provides`], so a variant missing from it
100/// is a port no bundle ever reports — which is exactly the bug that left
101/// `Port::Auth` out of `provides` when that was fifteen hand-written
102/// branches. Writing the list twice is what made it possible; writing it
103/// once is what stops it.
104///
105/// `#[non_exhaustive]` does not make an in-crate match non-exhaustive, so
106/// the check is real here even though a downstream `match` on `Port`
107/// needs a wildcard.
108macro_rules! ports {
109    ($($variant:ident),+ $(,)?) => {
110        impl Port {
111            /// Every port.
112            pub const ALL: &'static [Port] = &[$(Port::$variant),+];
113        }
114
115        /// Never called. It exists so that a variant absent from the list
116        /// above is a compile error here.
117        #[expect(dead_code, reason = "its only job is to be exhaustive")]
118        fn every_port_is_in_all(port: Port) {
119            match port {
120                $(Port::$variant => {}),+
121            }
122        }
123    };
124}
125
126ports!(
127    Db,
128    Auth,
129    Mailer,
130    Captcha,
131    RateLimiter,
132    Signer,
133    KeyValue,
134    Blob,
135    Push,
136    Payments,
137    Tracker,
138    Realtime,
139    TextModel,
140    Classifier,
141    HttpClient,
142    Clock,
143    IdGen,
144    Defer,
145);
146
147impl Port {
148    pub fn name(&self) -> &'static str {
149        match self {
150            Port::Db => "Database",
151            Port::Auth => "Auth",
152            Port::Mailer => "Mailer",
153            Port::Captcha => "Captcha",
154            Port::RateLimiter => "RateLimiter",
155            Port::Signer => "Signer",
156            Port::KeyValue => "KeyValue",
157            Port::Blob => "Blob",
158            Port::Push => "Push",
159            Port::Payments => "Payments",
160            Port::Tracker => "Tracker",
161            Port::Realtime => "Realtime",
162            Port::TextModel => "TextModel",
163            Port::Classifier => "Classifier",
164            Port::HttpClient => "HttpClient",
165            Port::Clock => "Clock",
166            Port::IdGen => "IdGen",
167            Port::Defer => "Defer",
168        }
169    }
170}
171
172/// The per-request bundle of resolved port implementations plus the typed
173/// config the runtime built from environment/secrets.
174///
175/// Every port is optional: the Cloudflare runtime resolves what the venture's
176/// bindings actually provide and leaves the rest `None`.
177pub struct Ports {
178    pub config: Arc<dyn Config>,
179    pub db: Option<Arc<dyn Database>>,
180    /// Who a request's credentials speak for (issue #153). `None` is a
181    /// deployment that cannot identify a caller at all; a module needing
182    /// one declares [`Port::Auth`] and is refused composition here.
183    pub auth: Option<Arc<dyn Auth>>,
184    pub mailer: Option<Arc<dyn Mailer>>,
185    pub captcha: Option<Arc<dyn Captcha>>,
186    pub rate_limiter: Option<Arc<dyn RateLimiter>>,
187    pub signer: Option<Arc<dyn Signer>>,
188    pub kv: Option<Arc<dyn KeyValue>>,
189    pub blob: Option<Arc<dyn Blob>>,
190    pub push: Option<Arc<dyn Push>>,
191    pub payments: Option<Arc<dyn Payments>>,
192    pub tracker: Option<Arc<dyn Tracker>>,
193    pub realtime: Option<Arc<dyn Realtime>>,
194    /// A text completion by [`ModelTier`](crate::ModelTier), never by
195    /// vendor (issue #429).
196    pub text_model: Option<Arc<dyn TextModel>>,
197    /// A typed, calibrated decision by a set of
198    /// [`Question`](crate::Question)s, never by vendor (issue #456) —
199    /// the sibling of `text_model`.
200    pub classifier: Option<Arc<dyn Classifier>>,
201    pub http: Option<Arc<dyn HttpClient>>,
202    pub clock: Option<Arc<dyn Clock>>,
203    pub id_gen: Option<Arc<dyn IdGen>>,
204    pub defer: Option<Arc<dyn Defer>>,
205    /// Set by the runtime when the venture mounts sidecar modules. Not a
206    /// [`Port`], so `view_for` never copies it and no module can reach it.
207    pub dispatcher: Option<Arc<dyn Dispatcher>>,
208    /// How this deployment turns a host into a tenant, and a tenant into
209    /// its database (TENANT-ROUTING.md §3-§5). `None` is the *no registry*
210    /// deployment — Cloudflare, the browser, and native in development and
211    /// test — which resolves every host to the implicit tenant and hands
212    /// back [`Ports::db`].
213    ///
214    /// Not a [`Port`] for the same reason `dispatcher` is not: `view_for`
215    /// must never copy it into a module's view. A module reaches a
216    /// database through the `TenantConn` extractor, which can only give it
217    /// the one the request resolved.
218    pub tenants: Option<Arc<dyn crate::tenant::TenantRouting>>,
219}
220
221impl Ports {
222    /// An empty bundle with no ports resolved and an
223    /// [`EmptyConfig`](crate::config::EmptyConfig).
224    pub fn empty() -> Self {
225        Self::with_config(Arc::new(crate::config::EmptyConfig))
226    }
227
228    pub fn with_config(config: Arc<dyn Config>) -> Self {
229        Self {
230            config,
231            db: None,
232            auth: None,
233            mailer: None,
234            captcha: None,
235            rate_limiter: None,
236            signer: None,
237            kv: None,
238            blob: None,
239            push: None,
240            payments: None,
241            tracker: None,
242            realtime: None,
243            text_model: None,
244            classifier: None,
245            http: None,
246            clock: None,
247            id_gen: None,
248            defer: None,
249            dispatcher: None,
250            tenants: None,
251        }
252    }
253
254    /// The set of ports this bundle actually provides.
255    ///
256    /// Walks [`Port::ALL`] and asks [`Self::has`], so a variant added to
257    /// the enum cannot be left out of the answer: the match in `has` is
258    /// exhaustive and the compiler refuses a new one until it is
259    /// handled. Written out as fifteen `if`s, it had already lost one —
260    /// `Port::Auth` was missing, so this reported fourteen of fifteen
261    /// and a bundle with a verifier wired said it had none.
262    #[must_use]
263    pub fn provides(&self) -> Vec<Port> {
264        Port::ALL
265            .iter()
266            .copied()
267            .filter(|port| self.has(*port))
268            .collect()
269    }
270
271    /// Whether one port is wired in this bundle.
272    ///
273    /// The exhaustive match is the point. `dispatcher` and `tenants` are
274    /// deliberately absent from [`Port`] — no module may reach either —
275    /// so they have nothing to answer here.
276    #[must_use]
277    pub fn has(&self, port: Port) -> bool {
278        match port {
279            Port::Db => self.db.is_some(),
280            Port::Auth => self.auth.is_some(),
281            Port::Mailer => self.mailer.is_some(),
282            Port::Captcha => self.captcha.is_some(),
283            Port::RateLimiter => self.rate_limiter.is_some(),
284            Port::Signer => self.signer.is_some(),
285            Port::KeyValue => self.kv.is_some(),
286            Port::Blob => self.blob.is_some(),
287            Port::Push => self.push.is_some(),
288            Port::Payments => self.payments.is_some(),
289            Port::Tracker => self.tracker.is_some(),
290            Port::Realtime => self.realtime.is_some(),
291            Port::TextModel => self.text_model.is_some(),
292            Port::Classifier => self.classifier.is_some(),
293            Port::HttpClient => self.http.is_some(),
294            Port::Clock => self.clock.is_some(),
295            Port::IdGen => self.id_gen.is_some(),
296            Port::Defer => self.defer.is_some(),
297        }
298    }
299
300    /// A copy of this bundle in which every port the module did not declare
301    /// in `requires()` or `optional()` is `None`, so a module cannot use
302    /// what it did not declare (issue #3). Undeclared-but-provided ports are
303    /// logged once per module by `Harness::build`.
304    #[must_use]
305    pub fn view_for(&self, module: &dyn Module) -> Self {
306        let declared = module
307            .requires()
308            .iter()
309            .chain(module.optional())
310            .copied()
311            .collect::<Vec<_>>();
312        let allows = |p: &[Port], port: Port| p.contains(&port);
313        let mut view = Ports::with_config(self.config.clone());
314        if allows(&declared, Port::Db) {
315            view.db.clone_from(&self.db);
316        }
317        if allows(&declared, Port::Auth) {
318            view.auth.clone_from(&self.auth);
319        }
320        if allows(&declared, Port::Mailer) {
321            view.mailer.clone_from(&self.mailer);
322        }
323        if allows(&declared, Port::Captcha) {
324            view.captcha.clone_from(&self.captcha);
325        }
326        if allows(&declared, Port::RateLimiter) {
327            view.rate_limiter.clone_from(&self.rate_limiter);
328        }
329        if allows(&declared, Port::Signer) {
330            view.signer.clone_from(&self.signer);
331        }
332        if allows(&declared, Port::KeyValue) {
333            view.kv.clone_from(&self.kv);
334        }
335        if allows(&declared, Port::Blob) {
336            // Scope the store to this module's prefix, the blob equivalent of
337            // the table-ownership rule: a module cannot name another's objects.
338            view.blob = self.blob.as_ref().map(|blob| {
339                Arc::new(ScopedBlob::new(Arc::clone(blob), module.name())) as Arc<dyn Blob>
340            });
341        }
342        if allows(&declared, Port::Push) {
343            view.push.clone_from(&self.push);
344        }
345        if allows(&declared, Port::Payments) {
346            view.payments.clone_from(&self.payments);
347        }
348        if allows(&declared, Port::Tracker) {
349            view.tracker.clone_from(&self.tracker);
350        }
351        if allows(&declared, Port::Realtime) {
352            view.realtime.clone_from(&self.realtime);
353        }
354        if allows(&declared, Port::TextModel) {
355            view.text_model.clone_from(&self.text_model);
356        }
357        if allows(&declared, Port::Classifier) {
358            view.classifier.clone_from(&self.classifier);
359        }
360        if allows(&declared, Port::HttpClient) {
361            view.http.clone_from(&self.http);
362        }
363        if allows(&declared, Port::Clock) {
364            view.clock.clone_from(&self.clock);
365        }
366        if allows(&declared, Port::IdGen) {
367            view.id_gen.clone_from(&self.id_gen);
368        }
369        if allows(&declared, Port::Defer) {
370            view.defer.clone_from(&self.defer);
371        }
372        view
373    }
374}
375
376/// Log (once, at `Harness::build`) the provided ports a module did not
377/// declare — the ports `view_for` will hide from it.
378pub(crate) fn warn_undeclared_ports(module: &dyn Module, provided: &[Port]) {
379    for port in provided {
380        if !module.requires().contains(port) && !module.optional().contains(port) {
381            warn!(
382                module = module.name(),
383                port = port.name(),
384                "runtime provides a port the module did not declare; hiding it",
385            );
386        }
387    }
388}