Skip to main content

boatramp_types/
function.rs

1//! The FaaS **function** model — PLAN-faas FA-1.
2//!
3//! A **function** is the compute *artifact*: a versioned WASI component + its
4//! binding/capability config. It is the one primitive the engine runs (decision 1
5//! — "one primitive, two views"). A **handler** is *not* a resource — it is a
6//! function reached by an HTTP **route** trigger; likewise a *consumer* / *cron* is
7//! a function reached by a queue / timer trigger (decision 5). Triggers are their
8//! own objects, and many can point at one function version (decision 2).
9//!
10//! [`desugar`] lowers a site's deploy-scoped `handlers/consumers/crons/streams`
11//! into functions + triggers with **no behavioural change** — the mandatory
12//! non-breaking gate: a site's handlers must run identically before and after.
13//! It is a pure config→shape transform; the content hash / version id of each
14//! function is assigned later, at `sync`, when its component blob is uploaded.
15
16use std::collections::BTreeMap;
17
18use serde::{Deserialize, Serialize};
19
20use crate::config::{ConsumerConfig, DeployConfig, HandlerConfig, HandlerLimits, Overlap};
21use crate::file::FileEntry;
22
23/// A function's owner — a **site** or a **project/tenant**. Drives the KV/blob/sql
24/// binding prefix and the inherited RBAC (a site-scoped function gains no privilege
25/// over its site).
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum Owner {
29    /// Owned by a site: `fn/<site>/<name>`, binding prefix + RBAC of the site.
30    Site(String),
31    /// A top-level function owned by a project/tenant: `fn/<name>`.
32    Project(String),
33}
34
35impl std::fmt::Display for Owner {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        match self {
38            Self::Site(s) => write!(f, "site:{s}"),
39            Self::Project(p) => write!(f, "project:{p}"),
40        }
41    }
42}
43
44/// A function version's lifecycle (decision 3: `DeployPinned` is the default).
45#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(rename_all = "kebab-case")]
47pub enum Lifecycle {
48    /// Versions + rolls back atomically with the owning site's deploy.
49    #[default]
50    DeployPinned,
51    /// Its own version / alias / rollback, independent of any deploy.
52    Independent,
53}
54
55/// The execution substrate (decision 1: a per-function knob; `wasm` is the default
56/// and scales to zero by instantiation; the stronger-isolation substrates are the
57/// compute backends — see PLAN-compute-backends).
58#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub enum Runtime {
61    #[default]
62    Wasm,
63    Microvm,
64    Container,
65}
66
67impl Runtime {
68    /// The snake_case wire term (matches the serde `rename_all`).
69    pub fn as_str(self) -> &'static str {
70        match self {
71            Self::Wasm => "wasm",
72            Self::Microvm => "microvm",
73            Self::Container => "container",
74        }
75    }
76}
77
78impl std::fmt::Display for Runtime {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        f.write_str(self.as_str())
81    }
82}
83
84/// A function's binding/capability config — the `HandlerConfig` capability fields
85/// (imports, resource limits, static env) *minus its trigger* (route/methods, which
86/// become a [`Trigger`]), plus the [`Runtime`] knob. A `HandlerConfig` *is* a
87/// function's config, so the engine has one path.
88#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(default, deny_unknown_fields)]
90pub struct FunctionConfig {
91    /// Requested host capabilities (interface names).
92    #[serde(skip_serializing_if = "Vec::is_empty")]
93    pub imports: Vec<String>,
94    /// Optional resource limits (mem / timeout / fuel).
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub limits: Option<HandlerLimits>,
97    /// Static, non-secret environment.
98    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
99    pub env: BTreeMap<String, String>,
100    /// Env-var name → secret reference (`ENV_VAR` → `HOST_ENV`), resolved
101    /// server-side from the serve env at instantiation; the value is a
102    /// reference, never stored in the manifest or the control-plane store
103    /// (mirrors a site handler's `[handlers].secrets`).
104    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
105    pub secrets: BTreeMap<String, String>,
106    /// Execution substrate (default `wasm`).
107    pub runtime: Runtime,
108    /// Usage quota (FA-4). Absent / all-`None` ⇒ unlimited.
109    #[serde(default, skip_serializing_if = "FunctionQuota::is_unset")]
110    pub quota: FunctionQuota,
111    /// Signed inbound-webhook ingress (FA-5). Absent ⇒ no webhook endpoint.
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub webhook: Option<WebhookConfig>,
114    /// Function-to-function invoke allowlist (FI): the target names this function
115    /// may call through the `invoke` capability. Each entry may use `*` wildcards
116    /// (`*` = any sibling, `img-*` = a family, `resize` = one literal). Deny by
117    /// default: empty ⇒ the function cannot invoke anything, even if it imports
118    /// `invoke`. Only consulted when `imports` contains `invoke`.
119    #[serde(skip_serializing_if = "Vec::is_empty")]
120    pub invoke_targets: Vec<String>,
121    /// In-site tenancy decision for this function's `sql`/`orm` access (Dimension 0). Absent ⇒
122    /// *undeclared* (refused under the `multi-tenant` posture, treated as `Disabled` — plain
123    /// queries — under single-tenant/dev). `Scoped` opts into host-injected row scoping. Parsed via
124    /// the [`crate::tenancy::de_opt_tenancy`] bridge so it reads from RON (`project.cfg`) as well as
125    /// the stored JSON.
126    #[serde(
127        default,
128        deserialize_with = "crate::tenancy::de_opt_tenancy",
129        skip_serializing_if = "Option::is_none"
130    )]
131    pub tenancy: Option<crate::tenancy::Tenancy>,
132    /// JWKS/issuer config verifying the app bearer for a [`crate::tenancy::TenantSource::Token`]
133    /// tenant source when this function is invoked over HTTP (the function analogue of a site's
134    /// `[handlers.graphql.data].claims_from_token`). Absent ⇒ the token source can't verify, so it
135    /// resolves no value (fail-closed); the domain/none sources don't need it.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub token_claims: Option<crate::config::HandlerGraphqlTokenClaims>,
138}
139
140/// The signature scheme a webhook is verified under (FA-5).
141#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(rename_all = "snake_case")]
143pub enum WebhookAlgorithm {
144    /// `HMAC-SHA256(body, secret)`, hex — the GitHub/Stripe-style scheme.
145    #[default]
146    HmacSha256,
147}
148
149/// Signed inbound-webhook config for a function (FA-5). The verifying secret is a
150/// **reference to a host env var** (never stored plaintext — mirrors site
151/// secrets); the endpoint verifies the request signature over the raw body,
152/// constant-time, **before** the guest runs.
153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
154#[serde(deny_unknown_fields)]
155pub struct WebhookConfig {
156    /// The host env var holding the shared secret.
157    pub secret_env: String,
158    /// Signature scheme (default HMAC-SHA256).
159    #[serde(default)]
160    pub algorithm: WebhookAlgorithm,
161    /// The header carrying the hex signature (default `x-boatramp-signature`; a
162    /// leading `sha256=` is accepted and stripped).
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub signature_header: Option<String>,
165    /// Max request body accepted before verifying/dispatching (default 1 MiB).
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub max_body_bytes: Option<u64>,
168    /// If set, a **verified** webhook publishes its body onto the project bus at
169    /// this topic (a consumer subscribes with `bus:<topic>`) and returns `202`,
170    /// **without running any component** — the "external event → the fabric"
171    /// ingress, so a quick webhook needs no dedicated `wasi:http` function. Unset
172    /// ⇒ the verified webhook invokes the function as before.
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub publish: Option<String>,
175}
176
177impl WebhookConfig {
178    /// The signature header name (defaulted).
179    pub fn header(&self) -> &str {
180        self.signature_header
181            .as_deref()
182            .unwrap_or("x-boatramp-signature")
183    }
184    /// The body cap (defaulted to 1 MiB).
185    pub fn body_cap(&self) -> u64 {
186        self.max_body_bytes.unwrap_or(1024 * 1024)
187    }
188}
189
190impl FunctionConfig {
191    fn from_handler(h: &HandlerConfig) -> Self {
192        Self {
193            imports: h.imports.clone(),
194            limits: h.limits.clone(),
195            env: h.env.clone(),
196            // Site handlers carry their secret refs on the site config
197            // (`[handlers].secrets`), resolved by the handler dispatch path — a
198            // desugared handler-function starts with none of its own.
199            secrets: BTreeMap::new(),
200            runtime: Runtime::default(),
201            quota: FunctionQuota::default(),
202            webhook: None,
203            invoke_targets: Vec::new(),
204            // A desugared handler-function carries its own per-handler tenancy when declared;
205            // absent ⇒ inherit the site config (the dispatch path applies the site decision).
206            tenancy: h.tenancy.clone(),
207            token_claims: h.token_claims.clone(),
208        }
209    }
210    fn from_consumer(c: &ConsumerConfig) -> Self {
211        Self {
212            imports: c.imports.clone(),
213            // A consumer's own tenancy (typically `signed_context` on the async lane) + token
214            // claims are carried onto the desugared function it runs as.
215            tenancy: c.tenancy.clone(),
216            token_claims: c.token_claims.clone(),
217            ..Default::default()
218        }
219    }
220}
221
222/// A reference to a function (a `None` version = the function's active version).
223#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
224#[serde(deny_unknown_fields)]
225pub struct FunctionRef {
226    /// The function name (site-scoped names are `<site>/<name>`).
227    pub name: String,
228    /// A specific version id, or `None` for the active version.
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub version: Option<String>,
231}
232
233/// A **trigger** — a separate object (decision 2). Many triggers may point at the
234/// same function version (e.g. a route *and* a cron). A `target` of `None` is a
235/// host-native trigger (a stream fan-out has no component).
236#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
237#[serde(deny_unknown_fields)]
238pub struct Trigger {
239    /// What fires the trigger.
240    pub kind: TriggerKind,
241    /// The function it invokes, or `None` for a host-native trigger.
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub target: Option<FunctionRef>,
244}
245
246/// The event that fires a trigger. The role words (route/queue/cron/stream) are the
247/// familiar site-view names; each is just *a way to reach a function*.
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
249#[serde(tag = "type", rename_all = "snake_case")]
250pub enum TriggerKind {
251    /// An HTTP route — the "handler" shape. `host` scopes it to a virtualhost.
252    Route {
253        #[serde(default, skip_serializing_if = "Option::is_none")]
254        host: Option<String>,
255        path: String,
256        #[serde(default, skip_serializing_if = "Vec::is_empty")]
257        methods: Vec<String>,
258    },
259    /// A stable invoke name — `/api/functions/<name>` (the FaaS verb, FA-3).
260    Invoke { name: String },
261    /// A message topic — the "consumer" shape. An empty `group` is the default
262    /// work-queue; a non-empty `group` is a durable fan-out subscriber (every
263    /// message, its own cursor) starting at `start` on first subscription.
264    Queue {
265        topic: String,
266        #[serde(default, skip_serializing_if = "String::is_empty")]
267        group: String,
268        #[serde(default, skip_serializing_if = "crate::config::is_default_start")]
269        start: crate::config::StartPosition,
270    },
271    /// A cron schedule — the "cron" shape.
272    Cron {
273        schedule: String,
274        #[serde(default)]
275        overlap: Overlap,
276    },
277    /// An object-storage change under a prefix (FA-5).
278    Blob { prefix: String },
279    /// A signed inbound webhook (FA-5). `secret_env` names the host env var
280    /// holding the verifying secret (never the secret itself).
281    Webhook { path: String, secret_env: String },
282    /// Host-native SSE / WebSocket topic fan-out — the "stream" shape (no component,
283    /// so a stream trigger's `target` is `None`).
284    Stream {
285        topics: Vec<String>,
286        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
287        websocket: bool,
288        #[serde(default, skip_serializing_if = "Option::is_none")]
289        publish_topic: Option<String>,
290    },
291}
292
293impl std::fmt::Display for Trigger {
294    /// A short one-line label for the functions view (`route GET /x`, `queue t`, …).
295    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
296        match &self.kind {
297            TriggerKind::Route { path, methods, .. } => {
298                let m = if methods.is_empty() {
299                    "*".to_string()
300                } else {
301                    methods.join(",")
302                };
303                write!(f, "route {m} {path}")
304            }
305            TriggerKind::Invoke { name } => write!(f, "invoke {name}"),
306            TriggerKind::Queue { topic, .. } => write!(f, "queue {topic}"),
307            TriggerKind::Cron { schedule, .. } => write!(f, "cron {schedule}"),
308            TriggerKind::Blob { prefix } => write!(f, "blob {prefix}"),
309            TriggerKind::Webhook { path, .. } => write!(f, "webhook {path}"),
310            TriggerKind::Stream { topics, .. } => write!(f, "stream {}", topics.join(",")),
311        }
312    }
313}
314
315/// A **stored trigger** bound to a top-level function — the durable form the
316/// server dispatches (FA-3 *scheduled* + FA-5 *event sources*). Its owning
317/// function is the key context, so there is no separate `target`. Keyed under
318/// [`keys::trigger`].
319#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
320#[serde(deny_unknown_fields)]
321pub struct FunctionTrigger {
322    /// Unique id within the function (the key suffix).
323    pub id: String,
324    /// What fires it.
325    pub kind: TriggerKind,
326    /// Cron dedup: the minute-stamp (`hour*60 + minute` within the day, or a
327    /// monotonic per-fire stamp) this trigger last fired at — durable so a fire
328    /// isn't repeated across a restart or (in a cluster) a leader change.
329    #[serde(default, skip_serializing_if = "Option::is_none")]
330    pub last_fired_minute: Option<i64>,
331}
332
333/// A stored, content-addressed function resource (the FA-1/FA-2 keyspace form).
334#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
335#[serde(deny_unknown_fields)]
336pub struct Function {
337    /// The function name (unique within its owner).
338    pub name: String,
339    /// Who owns it (drives binding prefix + RBAC).
340    pub owner: Owner,
341    /// Immutable versions, newest last.
342    pub versions: Vec<FunctionVersion>,
343    /// The active version's id.
344    pub active: String,
345    /// Named aliases → version id (staging/previews; mirrors deploy aliases).
346    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
347    pub aliases: BTreeMap<String, String>,
348    /// Binding/capability config.
349    pub config: FunctionConfig,
350}
351
352/// A [`Function`] operation named a version id the function has no record of
353/// (`rollback`/`set_alias`). A typed error so a caller can distinguish it (e.g.
354/// map it to a 404) instead of string-matching a message.
355#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
356#[error("no version {id:?} in function {function:?}")]
357pub struct UnknownVersion {
358    /// The function the operation was on.
359    pub function: String,
360    /// The version id that was not found.
361    pub id: String,
362}
363
364impl Function {
365    /// A new top-level function with a single active version (the component blob).
366    /// The version id **is** the component's content hash (content-addressed).
367    pub fn new(
368        name: impl Into<String>,
369        owner: Owner,
370        component_hash: impl Into<String>,
371        config: FunctionConfig,
372        lifecycle: Lifecycle,
373        created: u64,
374    ) -> Self {
375        let hash = component_hash.into();
376        Self {
377            name: name.into(),
378            owner,
379            versions: vec![FunctionVersion {
380                id: hash.clone(),
381                component: hash.clone(),
382                created,
383                lifecycle,
384            }],
385            active: hash,
386            aliases: BTreeMap::new(),
387            config,
388        }
389    }
390
391    /// Add a version for `component_hash` (id = the hash) and make it active. If a
392    /// version with that hash already exists it is just re-activated (idempotent).
393    /// Returns the (active) version id.
394    pub fn upsert_version(
395        &mut self,
396        component_hash: impl Into<String>,
397        lifecycle: Lifecycle,
398        created: u64,
399    ) -> String {
400        let hash = component_hash.into();
401        if !self.versions.iter().any(|v| v.id == hash) {
402            self.versions.push(FunctionVersion {
403                id: hash.clone(),
404                component: hash.clone(),
405                created,
406                lifecycle,
407            });
408        }
409        self.active = hash.clone();
410        hash
411    }
412
413    /// Point `active` at an existing version id. `Err` if the version is unknown.
414    pub fn rollback(&mut self, to: &str) -> Result<(), UnknownVersion> {
415        if self.versions.iter().any(|v| v.id == to) {
416            self.active = to.to_string();
417            Ok(())
418        } else {
419            Err(UnknownVersion {
420                function: self.name.clone(),
421                id: to.to_string(),
422            })
423        }
424    }
425
426    /// Set `label` → an existing version id. `Err` if the version is unknown.
427    pub fn set_alias(&mut self, label: &str, version: &str) -> Result<(), UnknownVersion> {
428        if self.versions.iter().any(|v| v.id == version) {
429            self.aliases.insert(label.to_string(), version.to_string());
430            Ok(())
431        } else {
432            Err(UnknownVersion {
433                function: self.name.clone(),
434                id: version.to_string(),
435            })
436        }
437    }
438
439    /// Resolve a **reference** — an alias label or a version id — to the component
440    /// blob hash that backs it, if known. An alias label is resolved first, then a
441    /// version id (so a label named like an id still wins as a label).
442    pub fn resolve(&self, reference: &str) -> Option<&str> {
443        let id = self
444            .aliases
445            .get(reference)
446            .map(String::as_str)
447            .unwrap_or(reference);
448        self.versions
449            .iter()
450            .find(|v| v.id == id)
451            .map(|v| v.component.as_str())
452    }
453}
454
455/// One immutable version of a function.
456#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
457#[serde(deny_unknown_fields)]
458pub struct FunctionVersion {
459    /// Immutable content-hash id.
460    pub id: String,
461    /// The component blob hash.
462    pub component: String,
463    /// Unix creation time.
464    pub created: u64,
465    /// This version's lifecycle.
466    #[serde(default)]
467    pub lifecycle: Lifecycle,
468}
469
470/// The desugared shape of a function derived from a site's `DeployConfig`. The
471/// `component` is still a **path** within the deploy — its content hash / version
472/// id is assigned at `sync`, when the blob is uploaded (so this stays a pure
473/// config→shape transform, testable without a blob store).
474#[derive(Debug, Clone, PartialEq, Eq)]
475pub struct FunctionSpec {
476    /// Site-scoped function name (see [`handler_name`] / [`consumer_name`]).
477    pub name: String,
478    /// The component path within the deploy.
479    pub component: String,
480    /// Binding/capability config.
481    pub config: FunctionConfig,
482    /// Version lifecycle (deploy-scoped functions are `DeployPinned`).
483    pub lifecycle: Lifecycle,
484}
485
486/// How an invocation is delivered (FA-3). `Sync` runs inline and returns the
487/// function's response; `Async` durably enqueues the call, returns `202` with an
488/// id, and a drain worker runs it later (retried, then dead-lettered).
489#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
490#[serde(rename_all = "snake_case")]
491pub enum InvokeMode {
492    /// Run inline; the caller blocks on the function's response.
493    #[default]
494    Sync,
495    /// Durably enqueue; the caller gets an id to poll.
496    Async,
497}
498
499/// The lifecycle of a durable (async) invocation.
500#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
501#[serde(rename_all = "snake_case")]
502pub enum InvocationStatus {
503    /// Enqueued, not yet claimed by a drain worker.
504    #[default]
505    Queued,
506    /// Claimed and executing.
507    Running,
508    /// Completed (the function returned a response — any HTTP status).
509    Succeeded,
510    /// Exhausted its attempts and was dead-lettered.
511    Failed,
512}
513
514/// The captured response of a completed invocation (sync idempotency replay +
515/// async poll both read this). The body is base64 so the record is plain JSON.
516#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
517#[serde(deny_unknown_fields)]
518pub struct InvocationResult {
519    /// The HTTP status the function returned.
520    pub status: u16,
521    /// The response content type, if any.
522    #[serde(default, skip_serializing_if = "Option::is_none")]
523    pub content_type: Option<String>,
524    /// The response body, base64 (standard, no padding stripped).
525    pub body_b64: String,
526}
527
528/// A durable invocation record — the unit of the async queue and the receipt an
529/// idempotency key replays. Keyed under [`keys::invocation`].
530#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
531#[serde(deny_unknown_fields)]
532pub struct Invocation {
533    /// Opaque invocation id (also the queue key suffix).
534    pub id: String,
535    /// The function invoked.
536    pub function: String,
537    /// The function version that ran (or will run) — pinned at enqueue so a
538    /// later deploy can't silently change an in-flight async call.
539    pub version: String,
540    /// Delivery mode.
541    pub mode: InvokeMode,
542    /// Current status.
543    pub status: InvocationStatus,
544    /// The idempotency key that created it, if any.
545    #[serde(default, skip_serializing_if = "Option::is_none")]
546    pub idempotency_key: Option<String>,
547    /// Delivery attempts so far (async).
548    #[serde(default)]
549    pub attempts: u32,
550    /// Lease expiry (unix seconds) while an async invocation is `Running`: the
551    /// drain that claims it stamps `now + ceiling + margin`, and the drain
552    /// treats a `Running` record whose lease has elapsed as reclaimable — so a
553    /// node that crashes mid-invocation never orphans the job. `None` once the
554    /// invocation settles (or while still `Queued`).
555    #[serde(default, skip_serializing_if = "Option::is_none")]
556    pub lease_expires: Option<u64>,
557    /// The request body the function receives, base64.
558    #[serde(default, skip_serializing_if = "Option::is_none")]
559    pub request_b64: Option<String>,
560    /// The request content type forwarded to the function.
561    #[serde(default, skip_serializing_if = "Option::is_none")]
562    pub request_content_type: Option<String>,
563    /// The captured result once complete.
564    #[serde(default, skip_serializing_if = "Option::is_none")]
565    pub result: Option<InvocationResult>,
566    /// Unix create time.
567    pub created: u64,
568    /// Unix last-update time.
569    pub updated: u64,
570}
571
572impl Invocation {
573    /// Whether the invocation reached a terminal state.
574    pub fn is_terminal(&self) -> bool {
575        matches!(
576            self.status,
577            InvocationStatus::Succeeded | InvocationStatus::Failed
578        )
579    }
580}
581
582/// Per-function usage quota (FA-4) — `require`-style knobs enforced host-side,
583/// **fail-closed** (over the limit ⇒ `429`). Accounting, not billing: this bounds
584/// abuse, it does not charge. All limits are per node today (a cluster-wide token
585/// bucket is future work); `None` on a field means that dimension is unlimited.
586#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
587#[serde(default, deny_unknown_fields)]
588pub struct FunctionQuota {
589    /// Max invocations admitted within [`window_secs`](Self::window_secs) (a
590    /// fixed window: the counter resets when the window rolls over).
591    #[serde(skip_serializing_if = "Option::is_none")]
592    pub max_invocations: Option<u64>,
593    /// The rate-limit window length in seconds (defaults to 60 when a
594    /// `max_invocations` cap is set).
595    #[serde(skip_serializing_if = "Option::is_none")]
596    pub window_secs: Option<u64>,
597    /// Max concurrent in-flight invocations for this function (per node).
598    #[serde(skip_serializing_if = "Option::is_none")]
599    pub max_concurrent: Option<u32>,
600}
601
602impl FunctionQuota {
603    /// Whether any dimension is set (an all-`None` quota is a no-op the caller can
604    /// skip entirely).
605    pub fn is_unset(&self) -> bool {
606        self.max_invocations.is_none() && self.max_concurrent.is_none()
607    }
608    /// The effective rate-limit window (defaults to 60s).
609    pub fn window(&self) -> u64 {
610        self.window_secs.unwrap_or(60)
611    }
612}
613
614/// One invocation's measured cost, folded into a [`Metering`] aggregate.
615#[derive(Debug, Clone, Copy, PartialEq, Eq)]
616pub struct MeteringSample {
617    /// Whether the invocation succeeded (the guest produced a response).
618    pub success: bool,
619    /// Wall-clock duration in milliseconds (the server-side CPU-time proxy; true
620    /// fuel accounting needs the engine to surface post-completion cost).
621    pub duration_ms: u64,
622    /// Request bytes delivered to the function.
623    pub bytes_in: u64,
624    /// Response bytes the function produced.
625    pub bytes_out: u64,
626}
627
628/// Host-side usage aggregate for one function (FA-4), tenant-isolated under
629/// [`keys::metering`]. It also carries the fixed-window rate-limit counter so
630/// metering + quota share a single read-modify-write.
631#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
632#[serde(default, deny_unknown_fields)]
633pub struct Metering {
634    /// The function these counters belong to.
635    pub function: String,
636    /// Total invocations metered (sync + drained async).
637    pub invocations: u64,
638    /// Invocations whose guest produced a response.
639    pub successes: u64,
640    /// Invocations that failed to deliver (engine error / dead-lettered).
641    pub failures: u64,
642    /// Summed wall-clock duration, milliseconds.
643    pub duration_ms_total: u64,
644    /// Summed request bytes.
645    pub bytes_in_total: u64,
646    /// Summed response bytes.
647    pub bytes_out_total: u64,
648    /// The current rate-limit window's start (unix seconds).
649    pub window_start: u64,
650    /// Invocations admitted in the current window.
651    pub window_count: u64,
652    /// Last update (unix seconds).
653    pub updated: u64,
654}
655
656impl Metering {
657    /// A fresh aggregate for `function`.
658    pub fn new(function: impl Into<String>) -> Self {
659        Self {
660            function: function.into(),
661            ..Default::default()
662        }
663    }
664
665    /// Fold one invocation's cost into the usage counters.
666    pub fn record(&mut self, sample: &MeteringSample, now: u64) {
667        self.invocations += 1;
668        if sample.success {
669            self.successes += 1;
670        } else {
671            self.failures += 1;
672        }
673        self.duration_ms_total = self.duration_ms_total.saturating_add(sample.duration_ms);
674        self.bytes_in_total = self.bytes_in_total.saturating_add(sample.bytes_in);
675        self.bytes_out_total = self.bytes_out_total.saturating_add(sample.bytes_out);
676        self.updated = now;
677    }
678
679    /// Admit one invocation against the fixed-window rate limit, rolling the
680    /// window over when it has elapsed. Returns `true` if admitted (and records
681    /// the admission), `false` if the window is already at `max` (⇒ the caller
682    /// fails closed with `429`). An unset cap always admits.
683    pub fn admit(&mut self, quota: &FunctionQuota, now: u64) -> bool {
684        let Some(max) = quota.max_invocations else {
685            return true;
686        };
687        let window = quota.window();
688        if now.saturating_sub(self.window_start) >= window {
689            self.window_start = now;
690            self.window_count = 0;
691        }
692        if self.window_count >= max {
693            return false;
694        }
695        self.window_count += 1;
696        self.updated = now;
697        true
698    }
699}
700
701pub mod keys {
702    //! KV keyspace for a function (mirrors the deploy/alias immutability model), all
703    //! **project-scoped** under `project/<proj>/…` (0.2.0). `project` is passed as a
704    //! bare `&str` (this crate is wasm-clean and has no `ProjectRef`); callers in
705    //! `boatramp-core` pass `ProjectRef::as_str()`.
706
707    /// Function metadata.
708    pub fn meta(project: &str, name: &str) -> String {
709        format!("project/{project}/functions/{name}")
710    }
711    /// An immutable version.
712    pub fn version(project: &str, name: &str, id: &str) -> String {
713        format!("project/{project}/functions/{name}/versions/{id}")
714    }
715    /// A named alias → version id.
716    pub fn alias(project: &str, name: &str, label: &str) -> String {
717        format!("project/{project}/functions/{name}/alias/{label}")
718    }
719    /// A trigger bound to the function.
720    pub fn trigger(project: &str, name: &str, id: &str) -> String {
721        format!("project/{project}/functions/{name}/triggers/{id}")
722    }
723    /// A durable invocation record.
724    pub fn invocation(project: &str, name: &str, id: &str) -> String {
725        format!("project/{project}/functions/{name}/invocations/{id}")
726    }
727    /// The prefix under which all of a function's invocations live (queue scan).
728    pub fn invocations_prefix(project: &str, name: &str) -> String {
729        format!("project/{project}/functions/{name}/invocations/")
730    }
731    /// An idempotency key → invocation id pointer.
732    pub fn idempotency(project: &str, name: &str, key: &str) -> String {
733        format!("project/{project}/functions/{name}/idem/{key}")
734    }
735    /// The function's usage-metering aggregate (FA-4).
736    pub fn metering(project: &str, name: &str) -> String {
737        format!("project/{project}/metering/{name}")
738    }
739
740    /// The prefix listing every function record in a project (`meta` keys). A
741    /// function record's key has no further `/` after its name, so a lister filters
742    /// on that to skip the per-function sub-records.
743    pub fn functions_prefix(project: &str) -> String {
744        format!("project/{project}/functions/")
745    }
746    /// The prefix listing a function's triggers.
747    pub fn triggers_prefix(project: &str, name: &str) -> String {
748        format!("project/{project}/functions/{name}/triggers/")
749    }
750    /// The prefix listing every metering aggregate in a project.
751    pub fn metering_prefix(project: &str) -> String {
752        format!("project/{project}/metering/")
753    }
754}
755
756/// The site-scoped function name for an HTTP route handler — a slug of the route
757/// (`/api/hello` → `api-hello`, `/` → `root`).
758pub fn handler_name(route: &str) -> String {
759    let s = slug(route);
760    if s.is_empty() {
761        "root".to_string()
762    } else {
763        s
764    }
765}
766
767/// The site-scoped function name for a topic consumer (`orders` → `consumer-orders`).
768pub fn consumer_name(topic: &str) -> String {
769    format!("consumer-{}", slug(topic))
770}
771
772/// Lower-case alphanumeric slug, non-alnum runs collapsed to single `-`, trimmed.
773fn slug(s: &str) -> String {
774    let mut out = String::new();
775    let mut dash = false;
776    for c in s.chars() {
777        if c.is_ascii_alphanumeric() {
778            out.push(c.to_ascii_lowercase());
779            dash = false;
780        } else if !out.is_empty() && !dash {
781            out.push('-');
782            dash = true;
783        }
784    }
785    out.trim_matches('-').to_string()
786}
787
788/// Lower a site's deploy-scoped compute config into **functions + triggers**
789/// (decision 2), preserving behaviour exactly (the non-breaking gate):
790///
791/// - each `handler` → a `DeployPinned` [`FunctionSpec`] + a `Route` [`Trigger`];
792/// - each `consumer` → a [`FunctionSpec`] + a `Queue` trigger;
793/// - each `cron` → a `Cron` trigger onto the **function serving its route** (a
794///   second trigger on one function — N triggers → one function);
795/// - each `stream` → a host-native `Stream` trigger (`target: None`, no component).
796///
797/// Pure: no I/O, no hashing. The site handler surface (`routing.handlers`) is
798/// unchanged — this is the internal lowering that FA-1..FA-3 build on.
799pub fn desugar(cfg: &DeployConfig) -> (Vec<FunctionSpec>, Vec<Trigger>) {
800    let mut functions = Vec::new();
801    let mut triggers = Vec::new();
802
803    for h in &cfg.handlers {
804        let name = handler_name(&h.route);
805        functions.push(FunctionSpec {
806            name: name.clone(),
807            component: h.component.clone(),
808            config: FunctionConfig::from_handler(h),
809            lifecycle: Lifecycle::DeployPinned,
810        });
811        triggers.push(Trigger {
812            kind: TriggerKind::Route {
813                host: None,
814                path: h.route.clone(),
815                methods: h.methods.clone(),
816            },
817            target: Some(FunctionRef {
818                name,
819                version: None,
820            }),
821        });
822    }
823
824    for c in &cfg.consumers {
825        let name = consumer_name(&c.topic);
826        functions.push(FunctionSpec {
827            name: name.clone(),
828            component: c.component.clone(),
829            config: FunctionConfig::from_consumer(c),
830            lifecycle: Lifecycle::DeployPinned,
831        });
832        triggers.push(Trigger {
833            kind: TriggerKind::Queue {
834                topic: c.topic.clone(),
835                group: c.group.clone(),
836                start: c.start,
837            },
838            target: Some(FunctionRef {
839                name,
840                version: None,
841            }),
842        });
843    }
844
845    for cr in &cfg.crons {
846        // A cron fires an existing handler-function, addressed by its route.
847        let target = cfg
848            .handlers
849            .iter()
850            .find(|h| h.route == cr.route)
851            .map(|h| FunctionRef {
852                name: handler_name(&h.route),
853                version: None,
854            });
855        triggers.push(Trigger {
856            kind: TriggerKind::Cron {
857                schedule: cr.schedule.clone(),
858                overlap: cr.overlap,
859            },
860            target,
861        });
862    }
863
864    for s in &cfg.streams {
865        triggers.push(Trigger {
866            kind: TriggerKind::Stream {
867                topics: s.topics.clone(),
868                websocket: s.websocket,
869                publish_topic: s.publish_topic.clone(),
870            },
871            target: None,
872        });
873    }
874
875    (functions, triggers)
876}
877
878/// Materialize desugared specs into stored [`Function`]s for a site, resolving each
879/// component **path** to its blob hash via the deploy's file map (the blob hash is
880/// the content-addressed version id). `created` is the deploy's activation time.
881/// Specs whose component isn't in the file map are dropped (a validated deploy
882/// always has them). This is the derived, read-only view of a site's functions
883/// (FA-1); independently-stored top-level functions come with FA-2.
884pub fn materialize(
885    specs: &[FunctionSpec],
886    site: &str,
887    files: &BTreeMap<String, FileEntry>,
888    created: u64,
889) -> Vec<Function> {
890    specs
891        .iter()
892        .filter_map(|s| {
893            let hash = files.get(s.component.trim_start_matches('/'))?.hash.clone();
894            Some(Function {
895                name: s.name.clone(),
896                owner: Owner::Site(site.to_string()),
897                versions: vec![FunctionVersion {
898                    id: hash.clone(),
899                    component: hash.clone(),
900                    created,
901                    lifecycle: s.lifecycle,
902                }],
903                active: hash,
904                aliases: BTreeMap::new(),
905                config: s.config.clone(),
906            })
907        })
908        .collect()
909}
910
911/// One entry in the `GET /api/functions` view: a function plus its resolved
912/// active version and the triggers that reach it. The read-only projection the
913/// server returns and the CLI/console render.
914#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
915pub struct FunctionSummary {
916    /// Function name (`<site>/<name>` for site-scoped; bare for top-level).
917    pub name: String,
918    /// Owner (`site:<site>` or `project:<project>`).
919    pub owner: String,
920    /// Execution substrate.
921    pub runtime: String,
922    /// Active version id (the component blob hash).
923    pub version: String,
924    /// Rendered triggers that reach this function.
925    pub triggers: Vec<String>,
926}
927
928#[cfg(test)]
929mod tests {
930    use super::*;
931    use crate::config::{ConsumerConfig, CronConfig, HandlerConfig, StreamConfig};
932
933    fn handler(route: &str, component: &str, methods: &[&str], imports: &[&str]) -> HandlerConfig {
934        HandlerConfig {
935            tenancy: None,
936            token_claims: None,
937            route: route.into(),
938            methods: methods
939                .iter()
940                .map(std::string::ToString::to_string)
941                .collect(),
942            component: component.into(),
943            imports: imports
944                .iter()
945                .map(std::string::ToString::to_string)
946                .collect(),
947            streaming: false,
948            limits: None,
949            env: BTreeMap::new(),
950            invoke_targets: Vec::new(),
951        }
952    }
953
954    #[test]
955    fn slugs_and_names() {
956        assert_eq!(handler_name("/api/hello"), "api-hello");
957        assert_eq!(handler_name("/"), "root");
958        assert_eq!(handler_name("/a/b/*"), "a-b");
959        assert_eq!(consumer_name("orders.new"), "consumer-orders-new");
960    }
961
962    /// The mandatory **non-breaking gate**: desugaring preserves every handler,
963    /// consumer, cron, and stream's fields exactly — the same component, imports,
964    /// routes/methods, topics, and the cron→handler binding.
965    #[test]
966    fn desugar_preserves_all_compute_config() {
967        let cfg = DeployConfig {
968            handlers: vec![
969                handler("/api/hello", "hello.wasm", &["GET"], &["kv"]),
970                handler("/api/report", "report.wasm", &[], &[]),
971            ],
972            consumers: vec![ConsumerConfig {
973                tenancy: None,
974                token_claims: None,
975                topic: "orders".into(),
976                component: "orders.wasm".into(),
977                imports: vec!["sql".into()],
978                group: String::new(),
979                start: Default::default(),
980            }],
981            crons: vec![CronConfig {
982                schedule: "0 * * * *".into(),
983                route: "/api/report".into(),
984                overlap: Overlap::Skip,
985            }],
986            streams: vec![StreamConfig {
987                route: "/live".into(),
988                topics: vec!["ticks".into()],
989                websocket: false,
990                publish_topic: None,
991            }],
992            ..Default::default()
993        };
994
995        let (functions, triggers) = desugar(&cfg);
996
997        // Two handlers + one consumer → three functions.
998        assert_eq!(functions.len(), 3);
999        let hello = functions.iter().find(|f| f.name == "api-hello").unwrap();
1000        assert_eq!(hello.component, "hello.wasm");
1001        assert_eq!(hello.config.imports, vec!["kv".to_string()]);
1002        assert_eq!(hello.lifecycle, Lifecycle::DeployPinned);
1003        assert_eq!(hello.config.runtime, Runtime::Wasm);
1004        let consumer = functions
1005            .iter()
1006            .find(|f| f.name == "consumer-orders")
1007            .unwrap();
1008        assert_eq!(consumer.component, "orders.wasm");
1009        assert_eq!(consumer.config.imports, vec!["sql".to_string()]);
1010
1011        // Triggers: 2 routes + 1 queue + 1 cron + 1 stream = 5.
1012        assert_eq!(triggers.len(), 5);
1013
1014        // The route trigger carries the exact path + methods and targets its function.
1015        let route = triggers
1016            .iter()
1017            .find(|t| matches!(&t.kind, TriggerKind::Route { path, .. } if path == "/api/hello"))
1018            .unwrap();
1019        match &route.kind {
1020            TriggerKind::Route { methods, host, .. } => {
1021                assert_eq!(methods, &["GET".to_string()]);
1022                assert!(host.is_none());
1023            }
1024            _ => unreachable!(),
1025        }
1026        assert_eq!(route.target.as_ref().unwrap().name, "api-hello");
1027
1028        // The queue trigger.
1029        let queue = triggers
1030            .iter()
1031            .find(|t| matches!(&t.kind, TriggerKind::Queue { topic, .. } if topic == "orders"))
1032            .unwrap();
1033        assert_eq!(queue.target.as_ref().unwrap().name, "consumer-orders");
1034
1035        // The cron is a SECOND trigger on the /api/report function (N triggers → 1 fn).
1036        let cron = triggers
1037            .iter()
1038            .find(|t| matches!(&t.kind, TriggerKind::Cron { .. }))
1039            .unwrap();
1040        assert_eq!(cron.target.as_ref().unwrap().name, "api-report");
1041
1042        // The stream is host-native: a trigger with no function target.
1043        let stream = triggers
1044            .iter()
1045            .find(|t| matches!(&t.kind, TriggerKind::Stream { .. }))
1046            .unwrap();
1047        assert!(stream.target.is_none());
1048        match &stream.kind {
1049            TriggerKind::Stream { topics, .. } => assert_eq!(topics, &["ticks".to_string()]),
1050            _ => unreachable!(),
1051        }
1052    }
1053
1054    #[test]
1055    fn materialize_resolves_paths_to_blob_hashes() {
1056        let cfg = DeployConfig {
1057            handlers: vec![handler("/api/hello", "hello.wasm", &["GET"], &[])],
1058            ..Default::default()
1059        };
1060        let (specs, _) = desugar(&cfg);
1061        let files = BTreeMap::from([(
1062            "hello.wasm".to_string(),
1063            FileEntry {
1064                hash: "sha256:abc".into(),
1065                size: 10,
1066                content_type: None,
1067                variants: BTreeMap::new(),
1068            },
1069        )]);
1070        let funcs = materialize(&specs, "blog", &files, 1_800_000_000);
1071        assert_eq!(funcs.len(), 1);
1072        assert_eq!(funcs[0].name, "api-hello");
1073        assert_eq!(funcs[0].owner, Owner::Site("blog".into()));
1074        assert_eq!(funcs[0].active, "sha256:abc");
1075        assert_eq!(funcs[0].versions[0].component, "sha256:abc");
1076        assert_eq!(funcs[0].versions[0].created, 1_800_000_000);
1077        // A spec whose component blob is absent is dropped (no phantom function).
1078        assert!(materialize(&specs, "blog", &BTreeMap::new(), 0).is_empty());
1079    }
1080
1081    #[test]
1082    fn empty_config_desugars_to_nothing() {
1083        let (functions, triggers) = desugar(&DeployConfig::default());
1084        assert!(functions.is_empty() && triggers.is_empty());
1085    }
1086
1087    #[test]
1088    fn model_serde_round_trips() {
1089        let f = Function {
1090            name: "resize".into(),
1091            owner: Owner::Project("acme".into()),
1092            versions: vec![FunctionVersion {
1093                id: "v1abc".into(),
1094                component: "blob:deadbeef".into(),
1095                created: 1_800_000_000,
1096                lifecycle: Lifecycle::Independent,
1097            }],
1098            active: "v1abc".into(),
1099            aliases: BTreeMap::from([("prod".into(), "v1abc".into())]),
1100            config: FunctionConfig {
1101                imports: vec!["blobstore".into()],
1102                runtime: Runtime::Microvm,
1103                ..Default::default()
1104            },
1105        };
1106        let json = serde_json::to_string(&f).unwrap();
1107        assert_eq!(serde_json::from_str::<Function>(&json).unwrap(), f);
1108
1109        // A trigger with a data-carrying kind + host-native (no target) both round-trip.
1110        for t in [
1111            Trigger {
1112                kind: TriggerKind::Route {
1113                    host: Some("example.com".into()),
1114                    path: "/x".into(),
1115                    methods: vec!["POST".into()],
1116                },
1117                target: Some(FunctionRef {
1118                    name: "resize".into(),
1119                    version: None,
1120                }),
1121            },
1122            Trigger {
1123                kind: TriggerKind::Stream {
1124                    topics: vec!["t".into()],
1125                    websocket: true,
1126                    publish_topic: Some("up".into()),
1127                },
1128                target: None,
1129            },
1130        ] {
1131            let j = serde_json::to_string(&t).unwrap();
1132            assert_eq!(serde_json::from_str::<Trigger>(&j).unwrap(), t);
1133        }
1134    }
1135
1136    #[test]
1137    fn versioning_alias_and_rollback() {
1138        let mut f = Function::new(
1139            "resize",
1140            Owner::Project("acme".into()),
1141            "hashA",
1142            FunctionConfig::default(),
1143            Lifecycle::Independent,
1144            1,
1145        );
1146        assert_eq!(f.active, "hashA");
1147        assert_eq!(f.versions.len(), 1);
1148
1149        // A new component → a new active version.
1150        f.upsert_version("hashB", Lifecycle::Independent, 2);
1151        assert_eq!(f.active, "hashB");
1152        assert_eq!(f.versions.len(), 2);
1153        // Re-deploying the same hash is idempotent (re-activates, no dup version).
1154        f.upsert_version("hashA", Lifecycle::Independent, 3);
1155        assert_eq!(f.active, "hashA");
1156        assert_eq!(f.versions.len(), 2);
1157
1158        // Alias to a known version; unknown is rejected.
1159        f.set_alias("prod", "hashB").unwrap();
1160        assert_eq!(f.aliases.get("prod").map(String::as_str), Some("hashB"));
1161        assert!(f.set_alias("prod", "ghost").is_err());
1162
1163        // Rollback to a known version; unknown is rejected.
1164        f.rollback("hashB").unwrap();
1165        assert_eq!(f.active, "hashB");
1166        assert!(f.rollback("ghost").is_err());
1167
1168        // `resolve` maps a version id OR an alias label to the component hash;
1169        // an unknown reference resolves to nothing.
1170        assert_eq!(f.resolve("hashA"), Some("hashA"));
1171        assert_eq!(f.resolve("prod"), Some("hashB")); // alias → version → component
1172        assert_eq!(f.resolve("ghost"), None);
1173    }
1174
1175    #[test]
1176    fn invocation_model_round_trips_and_reports_terminal() {
1177        let inv = Invocation {
1178            id: "inv-1".into(),
1179            function: "greeter".into(),
1180            version: "hashA".into(),
1181            mode: InvokeMode::Async,
1182            status: InvocationStatus::Queued,
1183            idempotency_key: Some("k".into()),
1184            attempts: 0,
1185            lease_expires: None,
1186            request_b64: Some("aGk=".into()),
1187            request_content_type: Some("text/plain".into()),
1188            result: None,
1189            created: 1,
1190            updated: 1,
1191        };
1192        assert!(!inv.is_terminal());
1193        let json = serde_json::to_string(&inv).unwrap();
1194        let back: Invocation = serde_json::from_str(&json).unwrap();
1195        assert_eq!(back, inv);
1196        // Enum wire forms are snake_case + stable.
1197        assert!(json.contains("\"mode\":\"async\""));
1198        assert!(json.contains("\"status\":\"queued\""));
1199
1200        let done = Invocation {
1201            status: InvocationStatus::Succeeded,
1202            result: Some(InvocationResult {
1203                status: 200,
1204                content_type: None,
1205                body_b64: "b2s=".into(),
1206            }),
1207            ..inv
1208        };
1209        assert!(done.is_terminal());
1210    }
1211
1212    #[test]
1213    fn metering_records_and_rate_limits() {
1214        let mut m = Metering::new("greeter");
1215        m.record(
1216            &MeteringSample {
1217                success: true,
1218                duration_ms: 5,
1219                bytes_in: 3,
1220                bytes_out: 7,
1221            },
1222            100,
1223        );
1224        m.record(
1225            &MeteringSample {
1226                success: false,
1227                duration_ms: 2,
1228                bytes_in: 0,
1229                bytes_out: 0,
1230            },
1231            101,
1232        );
1233        assert_eq!(m.invocations, 2);
1234        assert_eq!(m.successes, 1);
1235        assert_eq!(m.failures, 1);
1236        assert_eq!(m.duration_ms_total, 7);
1237        assert_eq!(m.bytes_out_total, 7);
1238        assert_eq!(m.updated, 101);
1239
1240        // Fixed-window rate limit: 2 per 10s window.
1241        let quota = FunctionQuota {
1242            max_invocations: Some(2),
1243            window_secs: Some(10),
1244            max_concurrent: None,
1245        };
1246        let mut r = Metering::new("greeter");
1247        assert!(r.admit(&quota, 1000)); // 1st in window
1248        assert!(r.admit(&quota, 1001)); // 2nd
1249        assert!(!r.admit(&quota, 1002)); // 3rd → rejected
1250        assert_eq!(r.window_count, 2);
1251        // The window rolls over after `window_secs`, resetting the counter.
1252        assert!(r.admit(&quota, 1011));
1253        assert_eq!(r.window_count, 1);
1254
1255        // An unset cap always admits and never touches the counter.
1256        let unset = FunctionQuota::default();
1257        let mut u = Metering::new("greeter");
1258        assert!(u.admit(&unset, 1));
1259        assert_eq!(u.window_count, 0);
1260        assert!(unset.is_unset());
1261    }
1262
1263    #[test]
1264    fn webhook_config_defaults_and_round_trips() {
1265        // Defaults: header + body cap.
1266        let w = WebhookConfig {
1267            secret_env: "HOOK_SECRET".into(),
1268            algorithm: WebhookAlgorithm::HmacSha256,
1269            signature_header: None,
1270            max_body_bytes: None,
1271            publish: None,
1272        };
1273        assert_eq!(w.header(), "x-boatramp-signature");
1274        assert_eq!(w.body_cap(), 1024 * 1024);
1275
1276        // A config carrying a webhook round-trips and the secret is an env *ref*.
1277        let cfg = FunctionConfig {
1278            webhook: Some(w),
1279            ..Default::default()
1280        };
1281        let json = serde_json::to_string(&cfg).unwrap();
1282        assert!(json.contains("\"secret_env\":\"HOOK_SECRET\""));
1283        assert!(json.contains("\"hmac_sha256\""));
1284        let back: FunctionConfig = serde_json::from_str(&json).unwrap();
1285        assert_eq!(back, cfg);
1286
1287        // Custom header + cap are honoured.
1288        let custom = WebhookConfig {
1289            secret_env: "S".into(),
1290            algorithm: WebhookAlgorithm::HmacSha256,
1291            signature_header: Some("x-hub-signature-256".into()),
1292            max_body_bytes: Some(4096),
1293            publish: None,
1294        };
1295        assert_eq!(custom.header(), "x-hub-signature-256");
1296        assert_eq!(custom.body_cap(), 4096);
1297    }
1298
1299    #[test]
1300    fn secret_refs_round_trip_as_references() {
1301        // A `secrets` map is a name→host-env-var *reference*: it round-trips in the
1302        // config and carries no literal secret value (the value is resolved from the
1303        // serve env at instantiation, never baked into the stored config/manifest).
1304        let cfg = FunctionConfig {
1305            env: BTreeMap::from([("STAGE".into(), "prod".into())]),
1306            secrets: BTreeMap::from([("DB_URL".into(), "PROD_DB_URL".into())]),
1307            ..Default::default()
1308        };
1309        let json = serde_json::to_string(&cfg).unwrap();
1310        // The map serializes the *reference* (the host env var name), not a value.
1311        assert!(json.contains("\"secrets\":{\"DB_URL\":\"PROD_DB_URL\"}"));
1312        let back: FunctionConfig = serde_json::from_str(&json).unwrap();
1313        assert_eq!(back, cfg);
1314        assert_eq!(
1315            back.secrets.get("DB_URL").map(String::as_str),
1316            Some("PROD_DB_URL")
1317        );
1318
1319        // An empty `secrets` map is elided (same serde shape as the handler's).
1320        let bare = FunctionConfig::default();
1321        assert!(!serde_json::to_string(&bare).unwrap().contains("secrets"));
1322    }
1323
1324    #[test]
1325    fn keyspace_is_stable() {
1326        assert_eq!(
1327            keys::meta("default", "resize"),
1328            "project/default/functions/resize"
1329        );
1330        assert_eq!(
1331            keys::version("default", "resize", "v1"),
1332            "project/default/functions/resize/versions/v1"
1333        );
1334        assert_eq!(
1335            keys::alias("default", "resize", "prod"),
1336            "project/default/functions/resize/alias/prod"
1337        );
1338        assert_eq!(
1339            keys::trigger("default", "resize", "t1"),
1340            "project/default/functions/resize/triggers/t1"
1341        );
1342        assert_eq!(
1343            keys::invocation("default", "resize", "inv-1"),
1344            "project/default/functions/resize/invocations/inv-1"
1345        );
1346        assert_eq!(
1347            keys::invocations_prefix("default", "resize"),
1348            "project/default/functions/resize/invocations/"
1349        );
1350        assert_eq!(
1351            keys::idempotency("default", "resize", "k-1"),
1352            "project/default/functions/resize/idem/k-1"
1353        );
1354        assert_eq!(
1355            keys::metering("default", "resize"),
1356            "project/default/metering/resize"
1357        );
1358        // A project other than the default keys under its own segment.
1359        assert_eq!(
1360            keys::meta("acme", "resize"),
1361            "project/acme/functions/resize"
1362        );
1363    }
1364}