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 actor;
9mod auth;
10mod blob;
11mod captcha;
12mod classifier;
13mod clock;
14mod custom_hostnames;
15mod database;
16mod defer;
17mod dispatcher;
18mod embedder;
19mod http;
20mod idgen;
21mod inbound_mail;
22mod kv;
23mod mailer;
24mod payments;
25mod push;
26mod rate_limiter;
27mod realtime;
28pub(crate) mod signer;
29mod text_model;
30mod tracker;
31mod vector_index;
32
33pub use actor::{
34    ACTOR_CALL_TIMEOUT, ActorContext, ActorError, ActorHandler, ActorHandlers, ActorStore,
35    ActorWrites, Actors, MAX_ACTOR_KEY_BYTES, MAX_ACTOR_KIND_BYTES, MAX_ACTOR_MESSAGE_BYTES,
36    MAX_ACTOR_REPLY_BYTES, MAX_ACTOR_VALUE_BYTES, ScopedActors, check_actor_call, run_actor_alarm,
37    run_actor_message, validate_actor_key, validate_actor_kind,
38};
39pub use auth::{Auth, AuthError, Caller, Subject, Unconfigured};
40pub use blob::{
41    Blob, BlobError, BlobMeta, BlobObject, BlobPage, BlobStream, DEFAULT_PRESIGN_TTL,
42    MAX_BLOB_BYTES, MAX_LARGE_BLOB_BYTES, MAX_LIST_LIMIT, MAX_MULTIPART_PARTS, MAX_PRESIGN_TTL,
43    MIN_MULTIPART_PART_BYTES, PartReceipt, PendingUpload, PresignedPut, ScopedBlob, UploadId,
44    blob_error_from_stream, check_blob_size, check_part_number, limit_stream,
45};
46pub use captcha::{Captcha, CaptchaBinding, CaptchaError, Verdict};
47pub use classifier::{
48    Answer, AnswerValue, Calibration, Classifier, ClassifierError, ClassifierProfile,
49    DEFAULT_MAX_STATE_CHARS, Question, validate_questions,
50};
51pub use clock::{Clock, SystemClock, timeout};
52pub use custom_hostnames::{
53    CertificateStatus, CustomHostname, CustomHostnameError, CustomHostnames, DnsRecordType,
54    HostnameClaim, HostnameRefusal, ProviderStatus, Validation, ValidationMethod, check_hostname,
55};
56pub use database::{Database, DbError, Row, Rows, Statement, TryFromValue};
57pub use defer::{Defer, NoopDefer};
58pub use dispatcher::{DispatchError, Dispatcher};
59pub use embedder::{EmbedError, Embedder, Embeddings};
60pub use http::{
61    BoundedHttpClient, DEFAULT_RESPONSE_TIMEOUT, HttpClient, HttpError, HttpPolicy,
62    MAX_CONCURRENT_REQUESTS, MAX_RESPONSE_BYTES, MAX_RESPONSE_TIMEOUT, StatusOnly,
63    declared_content_length,
64};
65pub use idgen::{IdGen, UlidIdGen};
66pub use inbound_mail::{InboundMailError, InboundMailSource, InboundMessage, receive_mail};
67pub use kv::{KeyValue, KvError};
68pub use mailer::{MailError, Mailer, Message, SendOutcome};
69pub use payments::{
70    Charge, CheckoutRequest, CheckoutSession, ConnectAccountLink, ConnectAccountLinkRequest,
71    Dispute, DisputeListRequest, DisputePage, DisputePhase, DisputeStatus, LineItem, Money,
72    Payments, PaymentsError, PortalSession, PortalSessionRequest, Refund, RefundRequest,
73    Subscription, SubscriptionCheckoutRequest, SubscriptionStatus, TransferCharge, UsageReport,
74    UsageReported, WebhookEvent,
75};
76pub use push::{
77    LocKeys, Notification, Platform, Priority, Push, PushError, PushOutcome, Recipient,
78    RoutingPush, retry_after, ttl_secs,
79};
80pub use rate_limiter::{Decision, Quota, RateLimitError, RateLimiter};
81pub use realtime::{Member, Realtime, RealtimeError, RoomContext, RoomHandler};
82pub use signer::{Kid, MAX_KID_NAME, Payload, SignatureError, Signer};
83pub use text_model::{
84    Capability, Completion, DEFAULT_MAX_TOKENS, ImageLimit, ImageMediaType,
85    MAX_IMAGE_ENCODED_BYTES, MAX_PROMPT_IMAGE_ENCODED_BYTES, MAX_PROMPT_IMAGES, ModelTier, Part,
86    Prompt, Role, RoutingTextModel, TextModel, TextModelError, ToolCall, ToolChoice, ToolResult,
87    ToolSpec, Turn, encode_image, encoded_image_len,
88};
89pub use tracker::{
90    Credential, Destination, Filed, InboundStatusError, RoutingTracker, Severity, StatusUpdate,
91    StatusWebhook, TicketComment, TicketDraft, TicketState, TicketStatus, Tracker, TrackerError,
92    receive_status,
93};
94pub use vector_index::{
95    ExactVectorIndex, MAX_NAMESPACE_BYTES, VectorFilter, VectorIndex, VectorIndexError,
96    VectorMatch, VectorNamespace, VectorRecord,
97};
98
99use crate::config::Config;
100use crate::module::Module;
101use std::sync::Arc;
102use tracing::warn;
103
104/// Every port a module can declare in `requires()` / `optional()`
105/// (architecture section 4).
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
107pub enum Port {
108    Db,
109    Auth,
110    Mailer,
111    Captcha,
112    RateLimiter,
113    Signer,
114    KeyValue,
115    Blob,
116    Push,
117    Payments,
118    Tracker,
119    Realtime,
120    TextModel,
121    Classifier,
122    VectorIndex,
123    Embedder,
124    CustomHostnames,
125    HttpClient,
126    Clock,
127    IdGen,
128    Defer,
129    /// Per-key serialized state with transactional storage and an alarm
130    /// (issue #583): the durable-object pattern behind one runtime-neutral
131    /// trait.
132    Actor,
133}
134
135/// Declares [`Port::ALL`] and, from the same list, a match that has to be
136/// exhaustive.
137///
138/// `ALL` is walked by [`Ports::provides`], so a variant missing from it
139/// is a port no bundle ever reports — which is exactly the bug that left
140/// `Port::Auth` out of `provides` when that was fifteen hand-written
141/// branches. Writing the list twice is what made it possible; writing it
142/// once is what stops it.
143///
144/// `#[non_exhaustive]` does not make an in-crate match non-exhaustive, so
145/// the check is real here even though a downstream `match` on `Port`
146/// needs a wildcard.
147macro_rules! ports {
148    ($($variant:ident),+ $(,)?) => {
149        impl Port {
150            /// Every port.
151            pub const ALL: &'static [Port] = &[$(Port::$variant),+];
152        }
153
154        /// Never called. It exists so that a variant absent from the list
155        /// above is a compile error here.
156        #[expect(dead_code, reason = "its only job is to be exhaustive")]
157        fn every_port_is_in_all(port: Port) {
158            match port {
159                $(Port::$variant => {}),+
160            }
161        }
162    };
163}
164
165ports!(
166    Db,
167    Auth,
168    Mailer,
169    Captcha,
170    RateLimiter,
171    Signer,
172    KeyValue,
173    Blob,
174    Push,
175    Payments,
176    Tracker,
177    Realtime,
178    TextModel,
179    Classifier,
180    VectorIndex,
181    Embedder,
182    CustomHostnames,
183    HttpClient,
184    Clock,
185    IdGen,
186    Defer,
187    Actor,
188);
189
190impl Port {
191    pub fn name(&self) -> &'static str {
192        match self {
193            Port::Db => "Database",
194            Port::Auth => "Auth",
195            Port::Mailer => "Mailer",
196            Port::Captcha => "Captcha",
197            Port::RateLimiter => "RateLimiter",
198            Port::Signer => "Signer",
199            Port::KeyValue => "KeyValue",
200            Port::Blob => "Blob",
201            Port::Push => "Push",
202            Port::Payments => "Payments",
203            Port::Tracker => "Tracker",
204            Port::Realtime => "Realtime",
205            Port::TextModel => "TextModel",
206            Port::Classifier => "Classifier",
207            Port::VectorIndex => "vector_index",
208            Port::Embedder => "embedder",
209            Port::CustomHostnames => "custom_hostnames",
210            Port::HttpClient => "HttpClient",
211            Port::Clock => "Clock",
212            Port::IdGen => "IdGen",
213            Port::Defer => "Defer",
214            Port::Actor => "actor",
215        }
216    }
217}
218
219/// The per-request bundle of resolved port implementations plus the typed
220/// config the runtime built from environment/secrets.
221///
222/// Every port is optional: the Cloudflare runtime resolves what the venture's
223/// bindings actually provide and leaves the rest `None`.
224pub struct Ports {
225    pub config: Arc<dyn Config>,
226    pub db: Option<Arc<dyn Database>>,
227    /// Who a request's credentials speak for (issue #153). `None` is a
228    /// deployment that cannot identify a caller at all; a module needing
229    /// one declares [`Port::Auth`] and is refused composition here.
230    pub auth: Option<Arc<dyn Auth>>,
231    pub mailer: Option<Arc<dyn Mailer>>,
232    pub captcha: Option<Arc<dyn Captcha>>,
233    pub rate_limiter: Option<Arc<dyn RateLimiter>>,
234    pub signer: Option<Arc<dyn Signer>>,
235    pub kv: Option<Arc<dyn KeyValue>>,
236    pub blob: Option<Arc<dyn Blob>>,
237    pub push: Option<Arc<dyn Push>>,
238    pub payments: Option<Arc<dyn Payments>>,
239    pub tracker: Option<Arc<dyn Tracker>>,
240    pub realtime: Option<Arc<dyn Realtime>>,
241    /// A text completion by [`ModelTier`](crate::ModelTier), never by
242    /// vendor (issue #429).
243    pub text_model: Option<Arc<dyn TextModel>>,
244    /// A typed, calibrated decision by a set of
245    /// [`Question`](crate::Question)s, never by vendor (issue #456) —
246    /// the sibling of `text_model`.
247    pub classifier: Option<Arc<dyn Classifier>>,
248    /// Nearest-neighbour search over tenant-scoped namespaces (issue
249    /// #561), the output side of embeddings, as `text_model` is.
250    pub vector_index: Option<Arc<dyn VectorIndex>>,
251    /// Text into embedding vectors (issue #561), the input side of
252    /// [`Ports::vector_index`].
253    pub embedder: Option<Arc<dyn Embedder>>,
254    /// Custom hostnames a customer owns, served by a venture (issue
255    /// #590) — Cloudflare for `SaaS` behind the same trait on every
256    /// runtime.
257    pub custom_hostnames: Option<Arc<dyn CustomHostnames>>,
258    pub http: Option<Arc<dyn HttpClient>>,
259    pub clock: Option<Arc<dyn Clock>>,
260    pub id_gen: Option<Arc<dyn IdGen>>,
261    pub defer: Option<Arc<dyn Defer>>,
262    /// Per-key serialized state with transactional storage and an alarm
263    /// (issue #583). The harness wraps it in a [`ScopedActors`] per module,
264    /// so a module only reaches the kinds it declared.
265    pub actors: Option<Arc<dyn Actors>>,
266    /// Set by the runtime when the venture mounts sidecar modules. Not a
267    /// [`Port`], so `view_for` never copies it and no module can reach it.
268    pub dispatcher: Option<Arc<dyn Dispatcher>>,
269    /// How this deployment turns a host into a tenant, and a tenant into
270    /// its database (TENANT-ROUTING.md §3-§5). `None` is the *no registry*
271    /// deployment — Cloudflare, the browser, and native in development and
272    /// test — which resolves every host to the implicit tenant and hands
273    /// back [`Ports::db`].
274    ///
275    /// Not a [`Port`] for the same reason `dispatcher` is not: `view_for`
276    /// must never copy it into a module's view. A module reaches a
277    /// database through the `TenantConn` extractor, which can only give it
278    /// the one the request resolved.
279    pub tenants: Option<Arc<dyn crate::tenant::TenantRouting>>,
280}
281
282impl Ports {
283    /// An empty bundle with no ports resolved and an
284    /// [`EmptyConfig`](crate::config::EmptyConfig).
285    pub fn empty() -> Self {
286        Self::with_config(Arc::new(crate::config::EmptyConfig))
287    }
288
289    pub fn with_config(config: Arc<dyn Config>) -> Self {
290        Self {
291            config,
292            db: None,
293            auth: None,
294            mailer: None,
295            captcha: None,
296            rate_limiter: None,
297            signer: None,
298            kv: None,
299            blob: None,
300            push: None,
301            payments: None,
302            tracker: None,
303            realtime: None,
304            text_model: None,
305            classifier: None,
306            vector_index: None,
307            embedder: None,
308            custom_hostnames: None,
309            http: None,
310            clock: None,
311            id_gen: None,
312            defer: None,
313            actors: None,
314            dispatcher: None,
315            tenants: None,
316        }
317    }
318
319    /// The set of ports this bundle actually provides.
320    ///
321    /// Walks [`Port::ALL`] and asks [`Self::has`], so a variant added to
322    /// the enum cannot be left out of the answer: the match in `has` is
323    /// exhaustive and the compiler refuses a new one until it is
324    /// handled. Written out as fifteen `if`s, it had already lost one —
325    /// `Port::Auth` was missing, so this reported fourteen of fifteen
326    /// and a bundle with a verifier wired said it had none.
327    #[must_use]
328    pub fn provides(&self) -> Vec<Port> {
329        Port::ALL
330            .iter()
331            .copied()
332            .filter(|port| self.has(*port))
333            .collect()
334    }
335
336    /// Whether one port is wired in this bundle.
337    ///
338    /// The exhaustive match is the point. `dispatcher` and `tenants` are
339    /// deliberately absent from [`Port`] — no module may reach either —
340    /// so they have nothing to answer here.
341    #[must_use]
342    pub fn has(&self, port: Port) -> bool {
343        match port {
344            Port::Db => self.db.is_some(),
345            Port::Auth => self.auth.is_some(),
346            Port::Mailer => self.mailer.is_some(),
347            Port::Captcha => self.captcha.is_some(),
348            Port::RateLimiter => self.rate_limiter.is_some(),
349            Port::Signer => self.signer.is_some(),
350            Port::KeyValue => self.kv.is_some(),
351            Port::Blob => self.blob.is_some(),
352            Port::Push => self.push.is_some(),
353            Port::Payments => self.payments.is_some(),
354            Port::Tracker => self.tracker.is_some(),
355            Port::Realtime => self.realtime.is_some(),
356            Port::TextModel => self.text_model.is_some(),
357            Port::Classifier => self.classifier.is_some(),
358            Port::VectorIndex => self.vector_index.is_some(),
359            Port::Embedder => self.embedder.is_some(),
360            Port::CustomHostnames => self.custom_hostnames.is_some(),
361            Port::HttpClient => self.http.is_some(),
362            Port::Clock => self.clock.is_some(),
363            Port::IdGen => self.id_gen.is_some(),
364            Port::Defer => self.defer.is_some(),
365            Port::Actor => self.actors.is_some(),
366        }
367    }
368
369    /// A copy of this bundle in which every port the module did not declare
370    /// in `requires()` or `optional()` is `None`, so a module cannot use
371    /// what it did not declare (issue #3). Undeclared-but-provided ports are
372    /// logged once per module by `Harness::build`.
373    #[must_use]
374    pub fn view_for(&self, module: &dyn Module) -> Self {
375        let declared = module
376            .requires()
377            .iter()
378            .chain(module.optional())
379            .copied()
380            .collect::<Vec<_>>();
381        let allows = |p: &[Port], port: Port| p.contains(&port);
382        let mut view = Ports::with_config(self.config.clone());
383        if allows(&declared, Port::Db) {
384            view.db.clone_from(&self.db);
385        }
386        if allows(&declared, Port::Auth) {
387            view.auth.clone_from(&self.auth);
388        }
389        if allows(&declared, Port::Mailer) {
390            view.mailer.clone_from(&self.mailer);
391        }
392        if allows(&declared, Port::Captcha) {
393            view.captcha.clone_from(&self.captcha);
394        }
395        if allows(&declared, Port::RateLimiter) {
396            view.rate_limiter.clone_from(&self.rate_limiter);
397        }
398        if allows(&declared, Port::Signer) {
399            view.signer.clone_from(&self.signer);
400        }
401        if allows(&declared, Port::KeyValue) {
402            view.kv.clone_from(&self.kv);
403        }
404        if allows(&declared, Port::Blob) {
405            // Scope the store to this module's prefix, the blob equivalent of
406            // the table-ownership rule: a module cannot name another's objects.
407            // The module's declared large-object ceiling rides along, enforced
408            // on the streamed and multipart writes (issue #586).
409            view.blob = self.blob.as_ref().map(|blob| {
410                Arc::new(
411                    ScopedBlob::new(Arc::clone(blob), module.name())
412                        .with_max_object_bytes(module.max_blob_object_bytes()),
413                ) as Arc<dyn Blob>
414            });
415        }
416        if allows(&declared, Port::Push) {
417            view.push.clone_from(&self.push);
418        }
419        if allows(&declared, Port::Payments) {
420            view.payments.clone_from(&self.payments);
421        }
422        if allows(&declared, Port::Tracker) {
423            view.tracker.clone_from(&self.tracker);
424        }
425        if allows(&declared, Port::Realtime) {
426            view.realtime.clone_from(&self.realtime);
427        }
428        if allows(&declared, Port::TextModel) {
429            view.text_model.clone_from(&self.text_model);
430        }
431        if allows(&declared, Port::Classifier) {
432            view.classifier.clone_from(&self.classifier);
433        }
434        if allows(&declared, Port::VectorIndex) {
435            view.vector_index.clone_from(&self.vector_index);
436        }
437        if allows(&declared, Port::Embedder) {
438            view.embedder.clone_from(&self.embedder);
439        }
440        if allows(&declared, Port::CustomHostnames) {
441            view.custom_hostnames.clone_from(&self.custom_hostnames);
442        }
443        if allows(&declared, Port::HttpClient) {
444            view.http.clone_from(&self.http);
445        }
446        if allows(&declared, Port::Clock) {
447            view.clock.clone_from(&self.clock);
448        }
449        if allows(&declared, Port::IdGen) {
450            view.id_gen.clone_from(&self.id_gen);
451        }
452        if allows(&declared, Port::Defer) {
453            view.defer.clone_from(&self.defer);
454        }
455        if allows(&declared, Port::Actor) {
456            // Scope the host to this module's declared kinds, the actor
457            // equivalent of the table-ownership rule.
458            view.actors = self.actors.as_ref().map(|actors| {
459                Arc::new(ScopedActors::new(
460                    Arc::clone(actors),
461                    module.name(),
462                    module.actor_kinds(),
463                    self.clock.clone(),
464                )) as Arc<dyn Actors>
465            });
466        }
467        view
468    }
469}
470
471/// Log (once, at `Harness::build`) the provided ports a module did not
472/// declare — the ports `view_for` will hide from it.
473pub(crate) fn warn_undeclared_ports(module: &dyn Module, provided: &[Port]) {
474    for port in provided {
475        if !module.requires().contains(port) && !module.optional().contains(port) {
476            warn!(
477                module = module.name(),
478                port = port.name(),
479                "runtime provides a port the module did not declare; hiding it",
480            );
481        }
482    }
483}