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