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