Skip to main content

cufflink_fn/
lib.rs

1//! # cufflink-fn
2//!
3//! Write custom Cufflink handlers in Rust that compile to WASM.
4//!
5//! This crate wraps the raw WASM host ABI so you write normal Rust code
6//! instead of pointer manipulation. Use it with `cufflink` services running
7//! in WASM mode.
8//!
9//! ## Quick Start
10//!
11//! ```rust,ignore
12//! use cufflink_fn::prelude::*;
13//!
14//! cufflink_fn::init!();
15//!
16//! handler!(hello, |req: Request| {
17//!     let name = req.body()["name"].as_str().unwrap_or("world");
18//!     Response::json(&json!({"message": format!("Hello, {}!", name)}))
19//! });
20//! ```
21//!
22//! ## Architecture
23//!
24//! Organize your code in layers:
25//!
26//! - **Handlers** (thin) — parse request, call operation, return response
27//! - **Operations** (fat) — validation, business rules, orchestration
28//! - **Repos** (data) — pure SQL via [`db::query`] / [`db::execute`]
29
30use serde_json::Value;
31use std::collections::HashMap;
32
33/// Read the host response buffer after a batch host call and parse it as a
34/// JSON array. Returns `None` if the buffer was empty or non-JSON.
35#[cfg(target_arch = "wasm32")]
36pub(crate) fn read_batch_response() -> Option<Vec<Value>> {
37    let len = unsafe { get_host_response_len() };
38    if len <= 0 {
39        return None;
40    }
41    let mut buf = vec![0u8; len as usize];
42    let read = unsafe { get_host_response(buf.as_mut_ptr() as i32, len) };
43    if read <= 0 {
44        return None;
45    }
46    buf.truncate(read as usize);
47    let s = String::from_utf8(buf).ok()?;
48    let v: Value = serde_json::from_str(&s).ok()?;
49    v.as_array().cloned()
50}
51
52// ─── Raw FFI ─────────────────────────────────────────────────────────────────
53// These are the host functions provided by the Cufflink platform WASM runtime.
54// Users never call these directly — use the `db`, `nats`, and `log` modules.
55
56#[cfg(target_arch = "wasm32")]
57extern "C" {
58    #[link_name = "cufflink_log"]
59    fn cufflink_log(level: i32, msg_ptr: i32, msg_len: i32);
60    fn db_query(sql_ptr: i32, sql_len: i32) -> i32;
61    fn db_execute(sql_ptr: i32, sql_len: i32) -> i32;
62    fn get_host_response_len() -> i32;
63    fn get_host_response(buf_ptr: i32, buf_len: i32) -> i32;
64    fn nats_publish(subj_ptr: i32, subj_len: i32, payload_ptr: i32, payload_len: i32) -> i32;
65    fn nats_request(
66        subj_ptr: i32,
67        subj_len: i32,
68        payload_ptr: i32,
69        payload_len: i32,
70        timeout_ms: i32,
71    ) -> i32;
72    fn http_fetch(
73        method_ptr: i32,
74        method_len: i32,
75        url_ptr: i32,
76        url_len: i32,
77        headers_ptr: i32,
78        headers_len: i32,
79        body_ptr: i32,
80        body_len: i32,
81    ) -> i32;
82    fn get_config(key_ptr: i32, key_len: i32) -> i32;
83    fn s3_download(bucket_ptr: i32, bucket_len: i32, key_ptr: i32, key_len: i32) -> i32;
84    fn s3_download_many(items_ptr: i32, items_len: i32) -> i32;
85    fn http_fetch_many(items_ptr: i32, items_len: i32) -> i32;
86    fn image_transform_jpeg(
87        bucket_ptr: i32,
88        bucket_len: i32,
89        in_key_ptr: i32,
90        in_key_len: i32,
91        out_key_ptr: i32,
92        out_key_len: i32,
93        max_dim: i32,
94        quality: i32,
95    ) -> i32;
96    fn s3_presign_upload(
97        bucket_ptr: i32,
98        bucket_len: i32,
99        key_ptr: i32,
100        key_len: i32,
101        content_type_ptr: i32,
102        content_type_len: i32,
103        expires_secs: i32,
104    ) -> i32;
105    fn redis_get(key_ptr: i32, key_len: i32) -> i32;
106    fn redis_set(key_ptr: i32, key_len: i32, val_ptr: i32, val_len: i32, ttl_secs: i32) -> i32;
107    fn redis_del(key_ptr: i32, key_len: i32) -> i32;
108    fn redis_get_status(key_ptr: i32, key_len: i32) -> i32;
109    fn redis_mget(keys_ptr: i32, keys_len: i32) -> i32;
110    fn generate_uuid() -> i32;
111    fn current_time() -> i32;
112    fn context_tenant() -> i32;
113}
114
115// ─── Auth ────────────────────────────────────────────────────────────────────
116
117/// Authenticated user context, validated by the Cufflink platform.
118///
119/// The platform validates the JWT token (via Keycloak) and extracts claims
120/// before passing them to your handler. You never need to validate tokens
121/// yourself — the `auth` field is only present when the token is valid.
122///
123/// ```rust,ignore
124/// handler!(protected, |req: Request| {
125///     let auth = match req.require_auth() {
126///         Ok(auth) => auth,
127///         Err(resp) => return resp,
128///     };
129///     if !auth.has_role("admin") {
130///         return Response::error("Forbidden");
131///     }
132///     Response::json(&json!({"user": auth.sub}))
133/// });
134/// ```
135#[derive(Debug, Clone)]
136pub struct Auth {
137    /// Keycloak subject ID (unique user identifier).
138    pub sub: String,
139    /// Preferred username from Keycloak.
140    pub preferred_username: Option<String>,
141    /// Display name.
142    pub name: Option<String>,
143    /// Email address.
144    pub email: Option<String>,
145    /// Realm roles assigned to the user in Keycloak.
146    pub realm_roles: Vec<String>,
147    /// All other JWT claims (custom Keycloak mappers, resource_access, etc.).
148    pub claims: HashMap<String, Value>,
149    /// Cufflink permissions resolved from the service's tenant roles (e.g., `["staff:create", "items:*"]`).
150    pub permissions: Vec<String>,
151    /// Cufflink role names assigned to the user (e.g., `["admin", "manager"]`).
152    pub role_names: Vec<String>,
153    /// Whether this is a Keycloak service account (client credentials grant).
154    /// Service accounts bypass permission checks at the platform level.
155    pub is_service_account: bool,
156}
157
158impl Auth {
159    /// Check if the user has a specific Keycloak realm role.
160    pub fn has_role(&self, role: &str) -> bool {
161        self.realm_roles.iter().any(|r| r == role)
162    }
163
164    /// Check if the user has a specific Cufflink permission.
165    ///
166    /// Supports wildcards: `"staff:*"` matches any operation in the "staff" area,
167    /// and `"*"` matches everything.
168    ///
169    /// ```rust,ignore
170    /// if !auth.can("staff", "create") {
171    ///     return Response::error("Forbidden: missing staff:create permission");
172    /// }
173    /// ```
174    pub fn can(&self, area: &str, operation: &str) -> bool {
175        let required = format!("{}:{}", area, operation);
176        let wildcard = format!("{}:*", area);
177        self.permissions
178            .iter()
179            .any(|p| p == &required || p == &wildcard || p == "*")
180    }
181
182    /// Check if the user has a specific Cufflink role (by name).
183    pub fn has_cufflink_role(&self, role: &str) -> bool {
184        self.role_names.iter().any(|r| r == role)
185    }
186
187    /// Get a specific claim value by key.
188    pub fn claim(&self, key: &str) -> Option<&Value> {
189        self.claims.get(key)
190    }
191}
192
193// ─── JobContext ──────────────────────────────────────────────────────────────
194
195/// Context the platform attaches to a request when it was delivered via the
196/// long-running jobs runtime. Absent on direct HTTP invocations.
197///
198/// Use this when a handler needs to distinguish a fresh invocation from a
199/// worker redelivery — e.g. to skip an idempotency-sensitive side effect that
200/// the prior attempt already paid for.
201#[derive(Debug, Clone)]
202pub struct JobContext {
203    pub id: String,
204    pub attempt: u32,
205    pub max_attempts: u32,
206}
207
208impl JobContext {
209    /// `true` once the worker has already delivered this job at least once.
210    pub fn is_retry(&self) -> bool {
211        self.attempt > 1
212    }
213}
214
215// ─── Request ─────────────────────────────────────────────────────────────────
216
217/// An incoming HTTP request from the Cufflink platform.
218///
219/// The platform serializes the full request context (method, headers, body,
220/// tenant, service name, auth) into JSON and passes it to your handler.
221#[derive(Debug, Clone)]
222pub struct Request {
223    method: String,
224    handler: String,
225    headers: HashMap<String, String>,
226    body: Value,
227    raw_body: Vec<u8>,
228    tenant: String,
229    service: String,
230    auth: Option<Auth>,
231    job: Option<JobContext>,
232}
233
234impl Request {
235    /// Parse a `Request` from the JSON the platform provides.
236    pub fn from_json(json: &str) -> Option<Self> {
237        let v: Value = serde_json::from_str(json).ok()?;
238        let headers = v["headers"]
239            .as_object()
240            .map(|m| {
241                m.iter()
242                    .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
243                    .collect()
244            })
245            .unwrap_or_default();
246
247        let auth = v["auth"].as_object().map(|auth_obj| {
248            let a = Value::Object(auth_obj.clone());
249            Auth {
250                sub: a["sub"].as_str().unwrap_or("").to_string(),
251                preferred_username: a["preferred_username"].as_str().map(|s| s.to_string()),
252                name: a["name"].as_str().map(|s| s.to_string()),
253                email: a["email"].as_str().map(|s| s.to_string()),
254                realm_roles: a["realm_roles"]
255                    .as_array()
256                    .map(|arr| {
257                        arr.iter()
258                            .filter_map(|v| v.as_str().map(|s| s.to_string()))
259                            .collect()
260                    })
261                    .unwrap_or_default(),
262                claims: a["claims"]
263                    .as_object()
264                    .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
265                    .unwrap_or_default(),
266                permissions: a["permissions"]
267                    .as_array()
268                    .map(|arr| {
269                        arr.iter()
270                            .filter_map(|v| v.as_str().map(|s| s.to_string()))
271                            .collect()
272                    })
273                    .unwrap_or_default(),
274                role_names: a["role_names"]
275                    .as_array()
276                    .map(|arr| {
277                        arr.iter()
278                            .filter_map(|v| v.as_str().map(|s| s.to_string()))
279                            .collect()
280                    })
281                    .unwrap_or_default(),
282                is_service_account: a["is_service_account"].as_bool().unwrap_or(false),
283            }
284        });
285
286        let raw_body = v["body_raw_b64"]
287            .as_str()
288            .filter(|s| !s.is_empty())
289            .and_then(|s| {
290                use base64::{engine::general_purpose, Engine};
291                general_purpose::STANDARD.decode(s).ok()
292            })
293            .unwrap_or_default();
294
295        let job = v["_job"].as_object().and_then(|j| {
296            let id = j.get("id")?.as_str()?.to_string();
297            let attempt = u32::try_from(j.get("attempt")?.as_u64()?).ok()?;
298            let max_attempts = u32::try_from(j.get("max_attempts")?.as_u64()?).ok()?;
299            Some(JobContext {
300                id,
301                attempt,
302                max_attempts,
303            })
304        });
305
306        Some(Self {
307            method: v["method"].as_str().unwrap_or("GET").to_string(),
308            handler: v["handler"].as_str().unwrap_or("").to_string(),
309            headers,
310            body: v["body"].clone(),
311            raw_body,
312            tenant: v["tenant"].as_str().unwrap_or("").to_string(),
313            service: v["service"].as_str().unwrap_or("").to_string(),
314            auth,
315            job,
316        })
317    }
318
319    /// The HTTP method (GET, POST, PUT, DELETE).
320    pub fn method(&self) -> &str {
321        &self.method
322    }
323
324    /// The handler name from the URL path.
325    pub fn handler(&self) -> &str {
326        &self.handler
327    }
328
329    /// All HTTP headers as a map.
330    pub fn headers(&self) -> &HashMap<String, String> {
331        &self.headers
332    }
333
334    /// Get a specific header value.
335    pub fn header(&self, name: &str) -> Option<&str> {
336        self.headers.get(name).map(|s| s.as_str())
337    }
338
339    /// The parsed JSON body. Returns `Value::Null` if no body was sent.
340    pub fn body(&self) -> &Value {
341        &self.body
342    }
343
344    /// The raw, unparsed request body bytes as the caller sent them.
345    ///
346    /// Use this when verifying webhook signatures that HMAC over the
347    /// original payload (Stripe, OpenPhone/Quo, etc.) — the parsed
348    /// `body()` is canonicalized by `serde_json` and will not byte-match
349    /// what the sender signed. Returns an empty slice when no body was
350    /// sent.
351    pub fn raw_body(&self) -> &[u8] {
352        &self.raw_body
353    }
354
355    /// The tenant slug from the URL.
356    pub fn tenant(&self) -> &str {
357        &self.tenant
358    }
359
360    /// The service name from the URL.
361    pub fn service(&self) -> &str {
362        &self.service
363    }
364
365    /// Get the authenticated user context, if present.
366    ///
367    /// Returns `None` if no valid JWT or API key was provided with the request.
368    pub fn auth(&self) -> Option<&Auth> {
369        self.auth.as_ref()
370    }
371
372    /// Get the job context attached by the long-running jobs runtime.
373    ///
374    /// Returns `None` when the handler is invoked directly via HTTP. Present
375    /// for handlers annotated with `job(...)` and dispatched by a worker.
376    pub fn job(&self) -> Option<&JobContext> {
377        self.job.as_ref()
378    }
379
380    /// Require authentication. Returns the auth context or an error response.
381    ///
382    /// ```rust,ignore
383    /// handler!(protected, |req: Request| {
384    ///     let auth = match req.require_auth() {
385    ///         Ok(auth) => auth,
386    ///         Err(resp) => return resp,
387    ///     };
388    ///     Response::json(&json!({"user": auth.sub}))
389    /// });
390    /// ```
391    pub fn require_auth(&self) -> Result<&Auth, Response> {
392        self.auth.as_ref().ok_or_else(|| {
393            Response::json(&serde_json::json!({
394                "error": "Authentication required",
395                "status": 401
396            }))
397        })
398    }
399}
400
401// ─── Response ────────────────────────────────────────────────────────────────
402
403/// An HTTP response to return from your handler.
404#[derive(Debug, Clone)]
405pub struct Response {
406    data: String,
407    status: u16,
408}
409
410impl Response {
411    /// Return a JSON response with HTTP 200.
412    pub fn json(value: &Value) -> Self {
413        Self {
414            data: serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string()),
415            status: 200,
416        }
417    }
418
419    /// Return a plain text response (wrapped in a JSON string).
420    pub fn text(s: &str) -> Self {
421        Self::json(&Value::String(s.to_string()))
422    }
423
424    /// Return an error response with HTTP 400.
425    pub fn error(message: &str) -> Self {
426        Self {
427            data: serde_json::json!({"error": message}).to_string(),
428            status: 400,
429        }
430    }
431
432    /// Return a 404 Not Found error.
433    pub fn not_found(message: &str) -> Self {
434        Self {
435            data: serde_json::json!({"error": message}).to_string(),
436            status: 404,
437        }
438    }
439
440    /// Return a 403 Forbidden error.
441    pub fn forbidden(message: &str) -> Self {
442        Self {
443            data: serde_json::json!({"error": message}).to_string(),
444            status: 403,
445        }
446    }
447
448    /// Return an empty success response.
449    pub fn empty() -> Self {
450        Self::json(&serde_json::json!({"ok": true}))
451    }
452
453    /// Set a custom HTTP status code on the response.
454    pub fn with_status(mut self, status: u16) -> Self {
455        self.status = status;
456        self
457    }
458
459    /// Get the raw response string.
460    /// Encodes the status code into the response so the platform can extract it.
461    pub fn into_data(self) -> String {
462        if self.status == 200 {
463            // No wrapping needed for 200 — backwards compatible
464            self.data
465        } else {
466            // Wrap with __status so the platform can set the HTTP status code
467            serde_json::json!({
468                "__status": self.status,
469                "__body": serde_json::from_str::<Value>(&self.data).unwrap_or(Value::String(self.data)),
470            })
471            .to_string()
472        }
473    }
474}
475
476// ─── db module ───────────────────────────────────────────────────────────────
477
478/// Database access — run SQL queries against your service's tables.
479///
480/// All queries run in the tenant's schema automatically. You don't need
481/// to qualify table names with a schema prefix.
482pub mod db {
483    use super::*;
484
485    /// Run a SELECT query and return all rows as a `Vec<Value>`.
486    ///
487    /// Each row is a JSON object with column names as keys.
488    ///
489    /// ```rust,ignore
490    /// let users = db::query("SELECT id, name, email FROM users WHERE active = true");
491    /// for user in &users {
492    ///     log::info(&format!("User: {}", user["name"]));
493    /// }
494    /// ```
495    pub fn query(sql: &str) -> Vec<Value> {
496        #[cfg(target_arch = "wasm32")]
497        {
498            let bytes = sql.as_bytes();
499            let result = unsafe { db_query(bytes.as_ptr() as i32, bytes.len() as i32) };
500            if result < 0 {
501                return vec![];
502            }
503            read_host_response()
504        }
505        #[cfg(not(target_arch = "wasm32"))]
506        {
507            let _ = sql;
508            vec![]
509        }
510    }
511
512    /// Run a SELECT query and return the first row, or `None` if empty.
513    ///
514    /// ```rust,ignore
515    /// if let Some(user) = db::query_one("SELECT * FROM users WHERE id = 'abc'") {
516    ///     log::info(&format!("Found user: {}", user["name"]));
517    /// }
518    /// ```
519    pub fn query_one(sql: &str) -> Option<Value> {
520        query(sql).into_iter().next()
521    }
522
523    /// Run an INSERT, UPDATE, or DELETE statement.
524    ///
525    /// Returns the number of affected rows, or -1 on error.
526    ///
527    /// ```rust,ignore
528    /// let affected = db::execute("UPDATE orders SET status = 'shipped' WHERE id = 'abc'");
529    /// log::info(&format!("Updated {} rows", affected));
530    /// ```
531    pub fn execute(sql: &str) -> i32 {
532        #[cfg(target_arch = "wasm32")]
533        {
534            let bytes = sql.as_bytes();
535            unsafe { db_execute(bytes.as_ptr() as i32, bytes.len() as i32) }
536        }
537        #[cfg(not(target_arch = "wasm32"))]
538        {
539            let _ = sql;
540            0
541        }
542    }
543
544    /// Read the host response buffer (used internally after db_query).
545    #[cfg(target_arch = "wasm32")]
546    fn read_host_response() -> Vec<Value> {
547        let len = unsafe { get_host_response_len() };
548        if len <= 0 {
549            return vec![];
550        }
551        let mut buf = vec![0u8; len as usize];
552        let read = unsafe { get_host_response(buf.as_mut_ptr() as i32, len) };
553        if read <= 0 {
554            return vec![];
555        }
556        buf.truncate(read as usize);
557        let json_str = String::from_utf8_lossy(&buf);
558        serde_json::from_str(&json_str).unwrap_or_default()
559    }
560}
561
562// ─── nats module ─────────────────────────────────────────────────────────────
563
564/// Publish messages to NATS for event-driven communication.
565///
566/// Use this to notify other services, trigger subscriptions, or emit
567/// domain events.
568pub mod nats {
569    #[allow(unused_imports)]
570    use super::*;
571
572    /// Publish a message to a NATS subject.
573    ///
574    /// Returns `true` on success, `false` on failure.
575    ///
576    /// ```rust,ignore
577    /// nats::publish(
578    ///     "dw.acme.order-service.orders.created",
579    ///     &serde_json::json!({"order_id": "abc", "total": 4500}).to_string(),
580    /// );
581    /// ```
582    pub fn publish(subject: &str, payload: &str) -> bool {
583        #[cfg(target_arch = "wasm32")]
584        {
585            let subj_bytes = subject.as_bytes();
586            let payload_bytes = payload.as_bytes();
587            let result = unsafe {
588                nats_publish(
589                    subj_bytes.as_ptr() as i32,
590                    subj_bytes.len() as i32,
591                    payload_bytes.as_ptr() as i32,
592                    payload_bytes.len() as i32,
593                )
594            };
595            result == 0
596        }
597        #[cfg(not(target_arch = "wasm32"))]
598        {
599            let _ = (subject, payload);
600            true
601        }
602    }
603
604    /// Send a NATS request and wait for a reply (synchronous request-reply).
605    ///
606    /// Returns the reply payload as a string, or `None` on timeout/failure.
607    ///
608    /// ```rust,ignore
609    /// let reply = nats::request(
610    ///     "dw.acme.user-service.users.lookup",
611    ///     &serde_json::json!({"customer_id": "abc"}).to_string(),
612    ///     5000, // timeout in ms
613    /// );
614    /// ```
615    pub fn request(subject: &str, payload: &str, timeout_ms: i32) -> Option<String> {
616        #[cfg(target_arch = "wasm32")]
617        {
618            let subj_bytes = subject.as_bytes();
619            let payload_bytes = payload.as_bytes();
620            let result = unsafe {
621                nats_request(
622                    subj_bytes.as_ptr() as i32,
623                    subj_bytes.len() as i32,
624                    payload_bytes.as_ptr() as i32,
625                    payload_bytes.len() as i32,
626                    timeout_ms,
627                )
628            };
629            if result != 0 {
630                return None;
631            }
632            let len = unsafe { get_host_response_len() };
633            if len <= 0 {
634                return None;
635            }
636            let mut buf = vec![0u8; len as usize];
637            let read = unsafe { get_host_response(buf.as_mut_ptr() as i32, len) };
638            if read <= 0 {
639                return None;
640            }
641            String::from_utf8(buf[..read as usize].to_vec()).ok()
642        }
643        #[cfg(not(target_arch = "wasm32"))]
644        {
645            let _ = (subject, payload, timeout_ms);
646            None
647        }
648    }
649}
650
651// ─── log module ──────────────────────────────────────────────────────────────
652
653/// Structured logging from inside your WASM handler.
654///
655/// Messages appear in the platform's log output prefixed with `[wasm]`.
656pub mod log {
657    #[allow(unused_imports)]
658    use super::*;
659
660    /// Log an error message (level 0).
661    pub fn error(msg: &str) {
662        write(0, msg);
663    }
664
665    /// Log a warning message (level 1).
666    pub fn warn(msg: &str) {
667        write(1, msg);
668    }
669
670    /// Log an info message (level 2).
671    pub fn info(msg: &str) {
672        write(2, msg);
673    }
674
675    /// Log a debug message (level 3).
676    pub fn debug(msg: &str) {
677        write(3, msg);
678    }
679
680    fn write(level: i32, msg: &str) {
681        #[cfg(target_arch = "wasm32")]
682        {
683            let bytes = msg.as_bytes();
684            unsafe {
685                super::cufflink_log(level, bytes.as_ptr() as i32, bytes.len() as i32);
686            }
687        }
688        #[cfg(not(target_arch = "wasm32"))]
689        {
690            let _ = (level, msg);
691        }
692    }
693}
694
695// ─── http module ────────────────────────────────────────────────────────────
696
697/// Make HTTP requests from inside your WASM handler.
698///
699/// Use this to call external APIs (Keycloak admin, third-party services, etc.)
700/// from your handler code.
701pub mod http {
702    #[allow(unused_imports)]
703    use super::*;
704
705    /// Response from an HTTP request.
706    #[derive(Debug, Clone)]
707    pub struct FetchResponse {
708        /// HTTP status code (e.g., 200, 404, 500).
709        pub status: i32,
710        /// Response body as a string (may be base64-encoded for binary content).
711        pub body: String,
712        /// Body encoding: "utf8" for text, "base64" for binary content.
713        pub body_encoding: String,
714        /// Response headers.
715        pub headers: HashMap<String, String>,
716    }
717
718    impl FetchResponse {
719        /// Parse the response body as JSON.
720        pub fn json(&self) -> Option<Value> {
721            serde_json::from_str(&self.body).ok()
722        }
723
724        /// Check if the response status indicates success (2xx).
725        pub fn is_success(&self) -> bool {
726            (200..300).contains(&self.status)
727        }
728
729        /// Check if the body is base64-encoded (binary content).
730        pub fn is_base64(&self) -> bool {
731            self.body_encoding == "base64"
732        }
733    }
734
735    /// Make an HTTP request.
736    ///
737    /// ```rust,ignore
738    /// let resp = http::fetch("GET", "https://api.example.com/data", &[], None);
739    /// if let Some(resp) = resp {
740    ///     if resp.is_success() {
741    ///         log::info(&format!("Got: {}", resp.body));
742    ///     }
743    /// }
744    /// ```
745    pub fn fetch(
746        method: &str,
747        url: &str,
748        headers: &[(&str, &str)],
749        body: Option<&str>,
750    ) -> Option<FetchResponse> {
751        #[cfg(target_arch = "wasm32")]
752        {
753            let method_bytes = method.as_bytes();
754            let url_bytes = url.as_bytes();
755            let headers_map: HashMap<&str, &str> = headers.iter().copied().collect();
756            let headers_json = serde_json::to_string(&headers_map).unwrap_or_default();
757            let headers_bytes = headers_json.as_bytes();
758            let body_bytes = body.unwrap_or("").as_bytes();
759            let body_len = body.map(|b| b.len()).unwrap_or(0);
760
761            let result = unsafe {
762                http_fetch(
763                    method_bytes.as_ptr() as i32,
764                    method_bytes.len() as i32,
765                    url_bytes.as_ptr() as i32,
766                    url_bytes.len() as i32,
767                    headers_bytes.as_ptr() as i32,
768                    headers_bytes.len() as i32,
769                    body_bytes.as_ptr() as i32,
770                    body_len as i32,
771                )
772            };
773
774            if result < 0 {
775                return None;
776            }
777
778            read_fetch_response()
779        }
780        #[cfg(not(target_arch = "wasm32"))]
781        {
782            let _ = (method, url, headers, body);
783            None
784        }
785    }
786
787    /// Make a GET request.
788    pub fn get(url: &str, headers: &[(&str, &str)]) -> Option<FetchResponse> {
789        fetch("GET", url, headers, None)
790    }
791
792    /// Make a POST request with a body.
793    pub fn post(url: &str, headers: &[(&str, &str)], body: &str) -> Option<FetchResponse> {
794        fetch("POST", url, headers, Some(body))
795    }
796
797    /// Make a PUT request with a body.
798    pub fn put(url: &str, headers: &[(&str, &str)], body: &str) -> Option<FetchResponse> {
799        fetch("PUT", url, headers, Some(body))
800    }
801
802    /// Make a DELETE request.
803    pub fn delete(url: &str, headers: &[(&str, &str)]) -> Option<FetchResponse> {
804        fetch("DELETE", url, headers, None)
805    }
806
807    /// Make a PATCH request with a body.
808    pub fn patch(url: &str, headers: &[(&str, &str)], body: &str) -> Option<FetchResponse> {
809        fetch("PATCH", url, headers, Some(body))
810    }
811
812    /// A single request inside a [`fetch_many`] batch.
813    #[derive(Debug, Clone)]
814    pub struct FetchRequest<'a> {
815        pub method: &'a str,
816        pub url: &'a str,
817        pub headers: &'a [(&'a str, &'a str)],
818        pub body: Option<&'a str>,
819    }
820
821    /// Make N HTTP requests concurrently from the host. Per-item errors are
822    /// reported in the returned slot (other slots still succeed). Use this
823    /// when you'd otherwise call [`fetch`] in a loop — sequential blocking
824    /// fetches inside WASM are linear, the host fan-out is parallel.
825    ///
826    /// The host bounds concurrency (default 32) so an unbounded list won't
827    /// exhaust connections.
828    ///
829    /// ```rust,ignore
830    /// let results = http::fetch_many(&[
831    ///     http::FetchRequest { method: "GET", url: "https://a", headers: &[], body: None },
832    ///     http::FetchRequest { method: "GET", url: "https://b", headers: &[], body: None },
833    /// ]);
834    /// ```
835    pub fn fetch_many(requests: &[FetchRequest<'_>]) -> Vec<Result<FetchResponse, String>> {
836        #[cfg(target_arch = "wasm32")]
837        {
838            let items: Vec<Value> = requests
839                .iter()
840                .map(|r| {
841                    let h: HashMap<&str, &str> = r.headers.iter().copied().collect();
842                    serde_json::json!({
843                        "method": r.method,
844                        "url": r.url,
845                        "headers": h,
846                        "body": r.body,
847                    })
848                })
849                .collect();
850            let payload = serde_json::to_string(&items).unwrap_or_else(|_| "[]".to_string());
851            let bytes = payload.as_bytes();
852            let rc = unsafe { super::http_fetch_many(bytes.as_ptr() as i32, bytes.len() as i32) };
853            if rc < 0 {
854                return requests
855                    .iter()
856                    .map(|_| Err("http_fetch_many host call failed".to_string()))
857                    .collect();
858            }
859            super::read_batch_response()
860                .map(|v| v.into_iter().map(parse_fetch_slot).collect())
861                .unwrap_or_else(|| {
862                    requests
863                        .iter()
864                        .map(|_| Err("malformed host response".to_string()))
865                        .collect()
866                })
867        }
868        #[cfg(not(target_arch = "wasm32"))]
869        {
870            let _ = requests;
871            vec![]
872        }
873    }
874
875    #[cfg(target_arch = "wasm32")]
876    fn fetch_response_from_json(v: &Value) -> FetchResponse {
877        FetchResponse {
878            status: v["status"].as_i64().unwrap_or(0) as i32,
879            body: v["body"].as_str().unwrap_or("").to_string(),
880            body_encoding: v["body_encoding"].as_str().unwrap_or("utf8").to_string(),
881            headers: v["headers"]
882                .as_object()
883                .map(|m| {
884                    m.iter()
885                        .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
886                        .collect()
887                })
888                .unwrap_or_default(),
889        }
890    }
891
892    #[cfg(target_arch = "wasm32")]
893    fn parse_fetch_slot(slot: Value) -> Result<FetchResponse, String> {
894        if !slot["ok"].as_bool().unwrap_or(false) {
895            return Err(slot["error"]
896                .as_str()
897                .unwrap_or("unknown error")
898                .to_string());
899        }
900        Ok(fetch_response_from_json(&slot))
901    }
902
903    /// Read the host response buffer after http_fetch.
904    #[cfg(target_arch = "wasm32")]
905    fn read_fetch_response() -> Option<FetchResponse> {
906        let len = unsafe { get_host_response_len() };
907        if len <= 0 {
908            return None;
909        }
910        let mut buf = vec![0u8; len as usize];
911        let read = unsafe { get_host_response(buf.as_mut_ptr() as i32, len) };
912        if read <= 0 {
913            return None;
914        }
915        buf.truncate(read as usize);
916        let json_str = String::from_utf8_lossy(&buf);
917        let v: Value = serde_json::from_str(&json_str).ok()?;
918        Some(fetch_response_from_json(&v))
919    }
920}
921
922// ─── config module ──────────────────────────────────────────────────────
923
924/// Read service configuration values set via `cufflink config set`.
925///
926/// Config values are stored in the platform's `service_configs` table,
927/// scoped to your service. Use `cufflink config set KEY VALUE [--secret]`
928/// to set values via the CLI.
929pub mod config {
930    #[allow(unused_imports)]
931    use super::*;
932
933    /// Get a config value by key. Returns `None` if the key doesn't exist.
934    ///
935    /// ```rust,ignore
936    /// let api_key = config::get("ANTHROPIC_API_KEY");
937    /// if let Some(key) = api_key {
938    ///     log::info(&format!("API key loaded ({} chars)", key.len()));
939    /// }
940    /// ```
941    pub fn get(key: &str) -> Option<String> {
942        #[cfg(target_arch = "wasm32")]
943        {
944            let bytes = key.as_bytes();
945            let result = unsafe { get_config(bytes.as_ptr() as i32, bytes.len() as i32) };
946            if result < 0 {
947                return None;
948            }
949            read_config_response()
950        }
951        #[cfg(not(target_arch = "wasm32"))]
952        {
953            let _ = key;
954            None
955        }
956    }
957
958    /// Read the host response buffer after get_config.
959    #[cfg(target_arch = "wasm32")]
960    fn read_config_response() -> Option<String> {
961        let len = unsafe { get_host_response_len() };
962        if len <= 0 {
963            return None;
964        }
965        let mut buf = vec![0u8; len as usize];
966        let read = unsafe { get_host_response(buf.as_mut_ptr() as i32, len) };
967        if read <= 0 {
968            return None;
969        }
970        buf.truncate(read as usize);
971        String::from_utf8(buf).ok()
972    }
973}
974
975// ─── storage module ─────────────────────────────────────────────────────
976
977/// Download files from S3-compatible object storage using the platform's credentials.
978///
979/// The platform uses its own S3 credentials (configured at deployment time) to
980/// perform authenticated downloads. This works with AWS S3, Hetzner Object Storage,
981/// MinIO, and any S3-compatible service.
982pub mod storage {
983    #[allow(unused_imports)]
984    use super::*;
985
986    /// Download a file from S3 and return its contents as a base64-encoded string.
987    ///
988    /// Returns `None` if the download fails (bucket not found, key not found,
989    /// S3 not configured, etc.).
990    ///
991    /// ```rust,ignore
992    /// if let Some(base64_data) = storage::download("my-bucket", "images/photo.jpg") {
993    ///     log::info(&format!("Downloaded {} bytes of base64", base64_data.len()));
994    /// }
995    /// ```
996    pub fn download(bucket: &str, key: &str) -> Option<String> {
997        #[cfg(target_arch = "wasm32")]
998        {
999            let bucket_bytes = bucket.as_bytes();
1000            let key_bytes = key.as_bytes();
1001            let result = unsafe {
1002                s3_download(
1003                    bucket_bytes.as_ptr() as i32,
1004                    bucket_bytes.len() as i32,
1005                    key_bytes.as_ptr() as i32,
1006                    key_bytes.len() as i32,
1007                )
1008            };
1009            if result < 0 {
1010                return None;
1011            }
1012            read_storage_response()
1013        }
1014        #[cfg(not(target_arch = "wasm32"))]
1015        {
1016            let _ = (bucket, key);
1017            None
1018        }
1019    }
1020
1021    /// Generate a presigned PUT URL for uploading a file directly to S3.
1022    ///
1023    /// The returned URL is valid for `expires_secs` seconds and allows
1024    /// unauthenticated PUT requests. Clients can upload by sending a PUT
1025    /// request to the URL with the file data as the body.
1026    ///
1027    /// ```rust,ignore
1028    /// if let Some(url) = storage::presign_upload("my-bucket", "uploads/photo.jpg", "image/jpeg", 300) {
1029    ///     // Return this URL to the client for direct upload
1030    ///     log::info(&format!("Upload URL: {}", url));
1031    /// }
1032    /// ```
1033    pub fn presign_upload(
1034        bucket: &str,
1035        key: &str,
1036        content_type: &str,
1037        expires_secs: u64,
1038    ) -> Option<String> {
1039        #[cfg(target_arch = "wasm32")]
1040        {
1041            let bucket_bytes = bucket.as_bytes();
1042            let key_bytes = key.as_bytes();
1043            let ct_bytes = content_type.as_bytes();
1044            let result = unsafe {
1045                s3_presign_upload(
1046                    bucket_bytes.as_ptr() as i32,
1047                    bucket_bytes.len() as i32,
1048                    key_bytes.as_ptr() as i32,
1049                    key_bytes.len() as i32,
1050                    ct_bytes.as_ptr() as i32,
1051                    ct_bytes.len() as i32,
1052                    expires_secs as i32,
1053                )
1054            };
1055            if result < 0 {
1056                return None;
1057            }
1058            read_storage_response()
1059        }
1060        #[cfg(not(target_arch = "wasm32"))]
1061        {
1062            let _ = (bucket, key, content_type, expires_secs);
1063            None
1064        }
1065    }
1066
1067    /// Read the host response buffer after s3_download or s3_presign_upload.
1068    #[cfg(target_arch = "wasm32")]
1069    fn read_storage_response() -> Option<String> {
1070        let len = unsafe { get_host_response_len() };
1071        if len <= 0 {
1072            return None;
1073        }
1074        let mut buf = vec![0u8; len as usize];
1075        let read = unsafe { get_host_response(buf.as_mut_ptr() as i32, len) };
1076        if read <= 0 {
1077            return None;
1078        }
1079        buf.truncate(read as usize);
1080        String::from_utf8(buf).ok()
1081    }
1082
1083    /// Download N files from object storage concurrently. Each slot is
1084    /// either the decoded bytes or a per-item error message. Use this
1085    /// instead of looping over [`download`] when N is large — the host
1086    /// fans out in parallel with a bounded semaphore (default 32).
1087    pub fn download_many(items: &[(&str, &str)]) -> Vec<Result<Vec<u8>, String>> {
1088        #[cfg(target_arch = "wasm32")]
1089        {
1090            let payload_items: Vec<Value> = items
1091                .iter()
1092                .map(|(b, k)| serde_json::json!({ "bucket": b, "key": k }))
1093                .collect();
1094            let payload = serde_json::to_string(&payload_items).unwrap_or_else(|_| "[]".into());
1095            let bytes = payload.as_bytes();
1096            let rc = unsafe { super::s3_download_many(bytes.as_ptr() as i32, bytes.len() as i32) };
1097            if rc < 0 {
1098                return items
1099                    .iter()
1100                    .map(|_| Err("s3_download_many host call failed".to_string()))
1101                    .collect();
1102            }
1103            super::read_batch_response()
1104                .map(|v| v.into_iter().map(parse_download_slot).collect())
1105                .unwrap_or_else(|| {
1106                    items
1107                        .iter()
1108                        .map(|_| Err("malformed host response".to_string()))
1109                        .collect()
1110                })
1111        }
1112        #[cfg(not(target_arch = "wasm32"))]
1113        {
1114            let _ = items;
1115            vec![]
1116        }
1117    }
1118
1119    #[cfg(target_arch = "wasm32")]
1120    fn parse_download_slot(slot: Value) -> Result<Vec<u8>, String> {
1121        if !slot["ok"].as_bool().unwrap_or(false) {
1122            return Err(slot["error"]
1123                .as_str()
1124                .unwrap_or("unknown error")
1125                .to_string());
1126        }
1127        let b64 = slot["data_b64"]
1128            .as_str()
1129            .ok_or_else(|| "missing data_b64".to_string())?;
1130        use base64::{engine::general_purpose, Engine};
1131        general_purpose::STANDARD
1132            .decode(b64)
1133            .map_err(|e| format!("base64 decode failed: {}", e))
1134    }
1135}
1136
1137// ─── image module ───────────────────────────────────────────────────────
1138
1139/// Image transforms executed on the platform host.
1140///
1141/// The platform downloads the source from object storage, decodes it,
1142/// optionally downscales the longest edge to `max_dim` (Lanczos3),
1143/// JPEG-encodes at the requested quality, and writes the result back to
1144/// the same bucket. CPU work runs in a host-side blocking task so it
1145/// does not stall the async runtime.
1146pub mod image {
1147    #[allow(unused_imports)]
1148    use super::*;
1149
1150    /// Resize an image stored at `(bucket, in_key)`, JPEG-encode it, and
1151    /// upload to `(bucket, out_key)`.
1152    ///
1153    /// `max_dim == 0` skips resizing. `quality` must be in `1..=100`.
1154    /// Returns the number of bytes written, or `None` on failure (the
1155    /// host logs the underlying error).
1156    ///
1157    /// ```rust,ignore
1158    /// let bytes = image::transform_jpeg("media", "raw/photo.jpg", "raw/photo.display.jpg", 1024, 80);
1159    /// ```
1160    pub fn transform_jpeg(
1161        bucket: &str,
1162        in_key: &str,
1163        out_key: &str,
1164        max_dim: u32,
1165        quality: u8,
1166    ) -> Option<u32> {
1167        #[cfg(target_arch = "wasm32")]
1168        {
1169            let bucket_bytes = bucket.as_bytes();
1170            let in_key_bytes = in_key.as_bytes();
1171            let out_key_bytes = out_key.as_bytes();
1172            let result = unsafe {
1173                super::image_transform_jpeg(
1174                    bucket_bytes.as_ptr() as i32,
1175                    bucket_bytes.len() as i32,
1176                    in_key_bytes.as_ptr() as i32,
1177                    in_key_bytes.len() as i32,
1178                    out_key_bytes.as_ptr() as i32,
1179                    out_key_bytes.len() as i32,
1180                    max_dim as i32,
1181                    quality as i32,
1182                )
1183            };
1184            if result < 0 {
1185                return None;
1186            }
1187            Some(result as u32)
1188        }
1189        #[cfg(not(target_arch = "wasm32"))]
1190        {
1191            let _ = (bucket, in_key, out_key, max_dim, quality);
1192            None
1193        }
1194    }
1195}
1196
1197// ─── redis module ────────────────────────────────────────────────────────
1198
1199/// Read and write values in Redis (backed by the platform's Redis connection).
1200///
1201/// Use this for caching, session storage, or any key-value data that needs
1202/// to be shared across services or requests with low latency.
1203pub mod redis {
1204    #[allow(unused_imports)]
1205    use super::*;
1206
1207    /// Get a value from Redis by key. Returns `None` if the key doesn't exist
1208    /// or Redis is not configured.
1209    ///
1210    /// ```rust,ignore
1211    /// if let Some(cached) = redis::get("auth:perms:user-123") {
1212    ///     log::info(&format!("Cache hit: {}", cached));
1213    /// }
1214    /// ```
1215    pub fn get(key: &str) -> Option<String> {
1216        #[cfg(target_arch = "wasm32")]
1217        {
1218            let bytes = key.as_bytes();
1219            let result = unsafe { redis_get(bytes.as_ptr() as i32, bytes.len() as i32) };
1220            if result < 0 {
1221                return None;
1222            }
1223            read_redis_response()
1224        }
1225        #[cfg(not(target_arch = "wasm32"))]
1226        {
1227            let _ = key;
1228            None
1229        }
1230    }
1231
1232    /// Set a value in Redis. Use `ttl_secs = 0` for no expiry.
1233    ///
1234    /// Returns `true` on success, `false` on failure.
1235    ///
1236    /// ```rust,ignore
1237    /// redis::set("auth:perms:user-123", &perms_json, 3600); // 1 hour TTL
1238    /// ```
1239    pub fn set(key: &str, value: &str, ttl_secs: i32) -> bool {
1240        #[cfg(target_arch = "wasm32")]
1241        {
1242            let key_bytes = key.as_bytes();
1243            let val_bytes = value.as_bytes();
1244            let result = unsafe {
1245                redis_set(
1246                    key_bytes.as_ptr() as i32,
1247                    key_bytes.len() as i32,
1248                    val_bytes.as_ptr() as i32,
1249                    val_bytes.len() as i32,
1250                    ttl_secs,
1251                )
1252            };
1253            result == 0
1254        }
1255        #[cfg(not(target_arch = "wasm32"))]
1256        {
1257            let _ = (key, value, ttl_secs);
1258            true
1259        }
1260    }
1261
1262    /// Delete a key from Redis.
1263    ///
1264    /// Returns `true` on success, `false` on failure.
1265    ///
1266    /// ```rust,ignore
1267    /// redis::del("auth:perms:user-123");
1268    /// ```
1269    pub fn del(key: &str) -> bool {
1270        #[cfg(target_arch = "wasm32")]
1271        {
1272            let bytes = key.as_bytes();
1273            let result = unsafe { redis_del(bytes.as_ptr() as i32, bytes.len() as i32) };
1274            result == 0
1275        }
1276        #[cfg(not(target_arch = "wasm32"))]
1277        {
1278            let _ = key;
1279            true
1280        }
1281    }
1282
1283    /// Read the host response buffer after redis_get.
1284    #[cfg(target_arch = "wasm32")]
1285    fn read_redis_response() -> Option<String> {
1286        let len = unsafe { get_host_response_len() };
1287        if len <= 0 {
1288            return None;
1289        }
1290        let mut buf = vec![0u8; len as usize];
1291        let read = unsafe { get_host_response(buf.as_mut_ptr() as i32, len) };
1292        if read <= 0 {
1293            return None;
1294        }
1295        buf.truncate(read as usize);
1296        String::from_utf8(buf).ok()
1297    }
1298
1299    /// Like [`get`] but distinguishes "key absent" from "Redis error" so the
1300    /// caller can choose its failure mode at the callsite.
1301    ///
1302    /// * `Ok(Some(value))` — cache hit (value may be the empty string).
1303    /// * `Ok(None)` — key is not in the cache.
1304    /// * `Err(message)` — Redis is unavailable or returned an error.
1305    pub fn get_with_status(key: &str) -> Result<Option<String>, String> {
1306        #[cfg(target_arch = "wasm32")]
1307        {
1308            let bytes = key.as_bytes();
1309            let code =
1310                unsafe { super::redis_get_status(bytes.as_ptr() as i32, bytes.len() as i32) };
1311            if code == 1 {
1312                return Ok(None);
1313            }
1314            let body = super::read_host_response_string();
1315            if code < 0 {
1316                Err(err_from_response(body))
1317            } else {
1318                Ok(Some(body))
1319            }
1320        }
1321        #[cfg(not(target_arch = "wasm32"))]
1322        {
1323            let _ = key;
1324            Ok(None)
1325        }
1326    }
1327
1328    /// Fetch multiple keys in a single round-trip. Returns a vector parallel to
1329    /// `keys`: `Some(value)` for hits, `None` for misses, in input order.
1330    ///
1331    /// ```rust,ignore
1332    /// let flags = redis::mget(&["feature_flags:default:a", "feature_flags:default:b"])?;
1333    /// ```
1334    pub fn mget(keys: &[&str]) -> Result<Vec<Option<String>>, String> {
1335        if keys.is_empty() {
1336            return Ok(Vec::new());
1337        }
1338        #[cfg(target_arch = "wasm32")]
1339        {
1340            let payload = serde_json::to_string(keys).map_err(|e| e.to_string())?;
1341            let bytes = payload.as_bytes();
1342            let code = unsafe { super::redis_mget(bytes.as_ptr() as i32, bytes.len() as i32) };
1343            let response = super::read_host_response_string();
1344            if code < 0 {
1345                return Err(err_from_response(response));
1346            }
1347            serde_json::from_str(&response).map_err(|e| e.to_string())
1348        }
1349        #[cfg(not(target_arch = "wasm32"))]
1350        {
1351            Ok(vec![None; keys.len()])
1352        }
1353    }
1354
1355    #[cfg(target_arch = "wasm32")]
1356    fn err_from_response(body: String) -> String {
1357        if body.is_empty() {
1358            "redis error".to_string()
1359        } else {
1360            body
1361        }
1362    }
1363}
1364
1365/// Drain the host response buffer into a `String`. Returns an empty string
1366/// when nothing was written (either the host fn produced no body, or it set
1367/// `host_response` to an empty value). Callers that need to distinguish
1368/// "empty" from "absent" must use a separate return-code channel.
1369#[cfg(target_arch = "wasm32")]
1370fn read_host_response_string() -> String {
1371    let len = unsafe { get_host_response_len() };
1372    if len <= 0 {
1373        return String::new();
1374    }
1375    let mut buf = vec![0u8; len as usize];
1376    let read = unsafe { get_host_response(buf.as_mut_ptr() as i32, len) };
1377    if read <= 0 {
1378        return String::new();
1379    }
1380    buf.truncate(read as usize);
1381    String::from_utf8(buf).unwrap_or_default()
1382}
1383
1384// ─── util module ────────────────────────────────────────────────────────
1385
1386/// Utility functions for common operations in WASM handlers.
1387pub mod util {
1388    #[allow(unused_imports)]
1389    use super::*;
1390
1391    /// Generate a new random UUID v4 string.
1392    ///
1393    /// ```rust,ignore
1394    /// let id = util::generate_uuid();
1395    /// log::info(&format!("New ID: {}", id));
1396    /// ```
1397    /// Get the current UTC time as an RFC3339 string.
1398    ///
1399    /// In WASM, this calls the platform host function. Outside WASM, it uses `SystemTime`.
1400    ///
1401    /// ```rust,ignore
1402    /// let now = util::current_time();
1403    /// log::info(&format!("Current time: {}", now));
1404    /// ```
1405    pub fn current_time() -> String {
1406        #[cfg(target_arch = "wasm32")]
1407        {
1408            if unsafe { super::current_time() } < 0 {
1409                return String::new();
1410            }
1411            super::read_host_response_string()
1412        }
1413
1414        #[cfg(not(target_arch = "wasm32"))]
1415        {
1416            let secs = std::time::SystemTime::now()
1417                .duration_since(std::time::UNIX_EPOCH)
1418                .map(|d| d.as_secs())
1419                .unwrap_or(0);
1420            format!("1970-01-01T00:00:00Z+{}", secs)
1421        }
1422    }
1423
1424    pub fn generate_uuid() -> String {
1425        #[cfg(target_arch = "wasm32")]
1426        {
1427            if unsafe { super::generate_uuid() } < 0 {
1428                return String::new();
1429            }
1430            super::read_host_response_string()
1431        }
1432
1433        #[cfg(not(target_arch = "wasm32"))]
1434        {
1435            format!(
1436                "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
1437                std::time::SystemTime::now()
1438                    .duration_since(std::time::UNIX_EPOCH)
1439                    .map(|d| d.as_nanos() as u32)
1440                    .unwrap_or(0),
1441                std::process::id() as u16,
1442                0u16,
1443                0x8000u16,
1444                0u64,
1445            )
1446        }
1447    }
1448}
1449
1450// ─── context module ─────────────────────────────────────────────────────────
1451
1452/// Runtime context provided by the platform: tenant identity, service identity,
1453/// etc. Use these from client helpers that are called from inside handlers but
1454/// don't take a `Request` (e.g. shared cache wrappers).
1455pub mod context {
1456    #[allow(unused_imports)]
1457    use super::*;
1458
1459    /// Tenant slug of the currently-executing service (e.g. `"default"`).
1460    /// Returns an empty string if no platform context is available — outside
1461    /// WASM this is always the case.
1462    pub fn tenant() -> String {
1463        #[cfg(target_arch = "wasm32")]
1464        {
1465            if unsafe { super::context_tenant() } != 0 {
1466                return String::new();
1467            }
1468            super::read_host_response_string()
1469        }
1470        #[cfg(not(target_arch = "wasm32"))]
1471        {
1472            String::new()
1473        }
1474    }
1475}
1476
1477// ─── Handler runtime ─────────────────────────────────────────────────────────
1478
1479/// Internal function used by the `handler!` macro. Do not call directly.
1480#[doc(hidden)]
1481pub fn __run_handler<F>(ptr: i32, len: i32, f: F) -> i32
1482where
1483    F: FnOnce(Request) -> Response,
1484{
1485    // Read the request JSON from guest memory
1486    let request_json = unsafe {
1487        let slice = std::slice::from_raw_parts(ptr as *const u8, len as usize);
1488        String::from_utf8_lossy(slice).into_owned()
1489    };
1490
1491    // Parse the request
1492    let request = Request::from_json(&request_json).unwrap_or_else(|| Request {
1493        method: "GET".to_string(),
1494        handler: String::new(),
1495        headers: HashMap::new(),
1496        body: Value::Null,
1497        raw_body: Vec::new(),
1498        tenant: String::new(),
1499        service: String::new(),
1500        auth: None,
1501        job: None,
1502    });
1503
1504    // Call the user's handler
1505    let response = f(request);
1506    let response_bytes = response.into_data().into_bytes();
1507
1508    // Write response to guest memory: [4-byte LE length][data]
1509    let total = 4 + response_bytes.len();
1510    let layout = std::alloc::Layout::from_size_align(total, 1).expect("invalid layout");
1511    let out_ptr = unsafe { std::alloc::alloc(layout) };
1512
1513    unsafe {
1514        let len_bytes = (response_bytes.len() as u32).to_le_bytes();
1515        std::ptr::copy_nonoverlapping(len_bytes.as_ptr(), out_ptr, 4);
1516        std::ptr::copy_nonoverlapping(
1517            response_bytes.as_ptr(),
1518            out_ptr.add(4),
1519            response_bytes.len(),
1520        );
1521    }
1522
1523    out_ptr as i32
1524}
1525
1526// ─── Macros ──────────────────────────────────────────────────────────────────
1527
1528/// Initialize the cufflink-fn runtime. Call this once at the top of your `lib.rs`.
1529///
1530/// Exports the `alloc` function that the platform needs to pass data into
1531/// your WASM module.
1532///
1533/// ```rust,ignore
1534/// use cufflink_fn::prelude::*;
1535///
1536/// cufflink_fn::init!();
1537/// ```
1538#[macro_export]
1539macro_rules! init {
1540    () => {
1541        #[no_mangle]
1542        pub extern "C" fn alloc(size: i32) -> i32 {
1543            let layout = std::alloc::Layout::from_size_align(size as usize, 1).unwrap();
1544            unsafe { std::alloc::alloc(layout) as i32 }
1545        }
1546    };
1547}
1548
1549/// Define a handler function.
1550///
1551/// This macro generates the `#[no_mangle] extern "C"` boilerplate so your
1552/// handler is a plain Rust closure that receives a [`Request`] and returns
1553/// a [`Response`].
1554///
1555/// ```rust,ignore
1556/// use cufflink_fn::prelude::*;
1557///
1558/// cufflink_fn::init!();
1559///
1560/// handler!(get_stats, |req: Request| {
1561///     let rows = db::query("SELECT COUNT(*) as total FROM orders");
1562///     Response::json(&json!({"total": rows[0]["total"]}))
1563/// });
1564///
1565/// handler!(create_order, |req: Request| {
1566///     let body = req.body();
1567///     let customer = body["customer_id"].as_str().unwrap_or("unknown");
1568///     db::execute(&format!(
1569///         "INSERT INTO orders (customer_id, status) VALUES ('{}', 'pending')",
1570///         customer
1571///     ));
1572///     Response::json(&json!({"status": "created"}))
1573/// });
1574/// ```
1575#[macro_export]
1576macro_rules! handler {
1577    ($name:ident, |$req:ident : Request| $body:expr) => {
1578        #[no_mangle]
1579        pub extern "C" fn $name(ptr: i32, len: i32) -> i32 {
1580            $crate::__run_handler(ptr, len, |$req: $crate::Request| $body)
1581        }
1582    };
1583}
1584
1585// ─── Schema migration hook ───────────────────────────────────────────────────
1586
1587/// Helpers for the optional `on_migrate` schema migration hook.
1588///
1589/// Declared in your service:
1590///
1591/// ```rust,ignore
1592/// cufflink::service! {
1593///     name: "logistics-service",
1594///     mode: wasm,
1595///     tables: [PickupRequest],
1596///     on_migrate: "handle_on_migrate",
1597///     // ...
1598/// }
1599/// ```
1600///
1601/// And implemented as a handler that delegates to [`migrate::run`]:
1602///
1603/// ```rust,ignore
1604/// use cufflink_fn::prelude::*;
1605///
1606/// handler!(handle_on_migrate, |req: Request| {
1607///     migrate::run(req, |diff| {
1608///         if diff.added_column("pickup_requests", "approximate_item_count_min")
1609///             && diff.dropped_column("pickup_requests", "approximate_item_count")
1610///         {
1611///             db::execute("UPDATE pickup_requests \
1612///                          SET approximate_item_count_min = approximate_item_count \
1613///                          WHERE approximate_item_count_min IS NULL");
1614///         }
1615///         Ok(())
1616///     })
1617/// });
1618/// ```
1619///
1620/// The closure must be **idempotent** — cufflink may invoke it on retried
1621/// deploys, on no-op deploys, and on first-time deploys against a fresh
1622/// database. Use `WHERE … IS NULL` guards on every UPDATE.
1623pub mod migrate {
1624    use super::{Request, Response};
1625    pub use cufflink_types::SchemaDiff;
1626
1627    /// Parse the [`SchemaDiff`] from the request body and invoke `handler`.
1628    ///
1629    /// Returns a `200 {"ok": true}` response on success or a `400` with the
1630    /// error message if `handler` returns `Err`. If the request body fails
1631    /// to deserialise as a `SchemaDiff`, returns a `400` describing the
1632    /// parse error.
1633    pub fn run<F>(req: Request, handler: F) -> Response
1634    where
1635        F: FnOnce(SchemaDiff) -> Result<(), String>,
1636    {
1637        match serde_json::from_value::<SchemaDiff>(req.body().clone()) {
1638            Ok(diff) => match handler(diff) {
1639                Ok(()) => Response::json(&serde_json::json!({"ok": true})),
1640                Err(e) => Response::error(&e),
1641            },
1642            Err(e) => Response::error(&format!(
1643                "on_migrate: failed to parse SchemaDiff payload: {}",
1644                e
1645            )),
1646        }
1647    }
1648}
1649
1650// ─── Prelude ─────────────────────────────────────────────────────────────────
1651
1652/// Import everything you need to write handlers.
1653///
1654/// ```rust,ignore
1655/// use cufflink_fn::prelude::*;
1656/// ```
1657pub mod prelude {
1658    pub use crate::config;
1659    pub use crate::context;
1660    pub use crate::db;
1661    pub use crate::http;
1662    pub use crate::image;
1663    pub use crate::log;
1664    pub use crate::migrate;
1665    pub use crate::nats;
1666    pub use crate::redis;
1667    pub use crate::storage;
1668    pub use crate::util;
1669    pub use crate::Auth;
1670    pub use crate::JobContext;
1671    pub use crate::Request;
1672    pub use crate::Response;
1673    pub use serde_json::{json, Value};
1674}
1675
1676// ─── Tests ───────────────────────────────────────────────────────────────────
1677
1678#[cfg(test)]
1679mod tests {
1680    use super::*;
1681    use serde_json::json;
1682
1683    #[test]
1684    fn test_request_parsing() {
1685        let json = serde_json::to_string(&json!({
1686            "method": "POST",
1687            "handler": "checkout",
1688            "headers": {"content-type": "application/json"},
1689            "body": {"item": "widget", "qty": 3},
1690            "tenant": "acme",
1691            "service": "shop"
1692        }))
1693        .unwrap();
1694
1695        let req = Request::from_json(&json).unwrap();
1696        assert_eq!(req.method(), "POST");
1697        assert_eq!(req.handler(), "checkout");
1698        assert_eq!(req.tenant(), "acme");
1699        assert_eq!(req.service(), "shop");
1700        assert_eq!(req.body()["item"], "widget");
1701        assert_eq!(req.body()["qty"], 3);
1702        assert_eq!(req.header("content-type"), Some("application/json"));
1703    }
1704
1705    #[test]
1706    fn test_request_missing_fields() {
1707        let json = r#"{"method": "GET"}"#;
1708        let req = Request::from_json(json).unwrap();
1709        assert_eq!(req.method(), "GET");
1710        assert_eq!(req.handler(), "");
1711        assert_eq!(req.tenant(), "");
1712        assert_eq!(req.body(), &Value::Null);
1713        assert!(req.raw_body().is_empty());
1714    }
1715
1716    #[test]
1717    fn test_request_raw_body_round_trip() {
1718        use base64::{engine::general_purpose, Engine};
1719        let raw = br#"{"type":"message.delivered","data":{"object":{"id":"AC1"}}}"#;
1720        let json = serde_json::to_string(&json!({
1721            "method": "POST",
1722            "handler": "webhook",
1723            "headers": {"content-type": "application/json"},
1724            "body": serde_json::from_slice::<Value>(raw).unwrap(),
1725            "body_raw_b64": general_purpose::STANDARD.encode(raw),
1726            "tenant": "acme",
1727            "service": "shop",
1728        }))
1729        .unwrap();
1730        let req = Request::from_json(&json).unwrap();
1731        assert_eq!(req.raw_body(), raw);
1732    }
1733
1734    #[test]
1735    fn test_request_raw_body_invalid_base64_yields_empty() {
1736        let json = serde_json::to_string(&json!({
1737            "method": "POST",
1738            "handler": "webhook",
1739            "body": Value::Null,
1740            "body_raw_b64": "not%base64!",
1741            "tenant": "acme",
1742            "service": "shop",
1743        }))
1744        .unwrap();
1745        let req = Request::from_json(&json).unwrap();
1746        assert!(req.raw_body().is_empty());
1747    }
1748
1749    #[test]
1750    fn test_request_job_context_parsed_when_present() {
1751        let json = serde_json::to_string(&json!({
1752            "method": "POST",
1753            "handler": "handle_run_ai",
1754            "body": {"id": "item-1"},
1755            "tenant": "acme",
1756            "service": "asset",
1757            "_job": {
1758                "id": "11111111-1111-1111-1111-111111111111",
1759                "attempt": 2,
1760                "max_attempts": 3,
1761            },
1762        }))
1763        .unwrap();
1764        let req = Request::from_json(&json).unwrap();
1765        let job = req.job().expect("job context should be parsed");
1766        assert_eq!(job.id, "11111111-1111-1111-1111-111111111111");
1767        assert_eq!(job.attempt, 2);
1768        assert_eq!(job.max_attempts, 3);
1769        assert!(job.is_retry());
1770    }
1771
1772    #[test]
1773    fn test_request_job_context_absent_on_http_direct() {
1774        let json = serde_json::to_string(&json!({
1775            "method": "GET",
1776            "handler": "list",
1777            "body": Value::Null,
1778            "tenant": "acme",
1779            "service": "asset",
1780        }))
1781        .unwrap();
1782        let req = Request::from_json(&json).unwrap();
1783        assert!(req.job().is_none());
1784    }
1785
1786    #[test]
1787    fn test_request_job_context_first_attempt_is_not_retry() {
1788        let json = serde_json::to_string(&json!({
1789            "method": "POST",
1790            "handler": "handle_run_ai",
1791            "body": Value::Null,
1792            "tenant": "acme",
1793            "service": "asset",
1794            "_job": {
1795                "id": "22222222-2222-2222-2222-222222222222",
1796                "attempt": 1,
1797                "max_attempts": 2,
1798            },
1799        }))
1800        .unwrap();
1801        let req = Request::from_json(&json).unwrap();
1802        let job = req.job().expect("job context should be parsed");
1803        assert_eq!(job.attempt, 1);
1804        assert!(!job.is_retry());
1805    }
1806
1807    #[test]
1808    fn test_request_job_context_rejects_malformed_envelope() {
1809        let json = serde_json::to_string(&json!({
1810            "method": "POST",
1811            "handler": "handle_run_ai",
1812            "body": Value::Null,
1813            "tenant": "acme",
1814            "service": "asset",
1815            "_job": {
1816                "id": "33333333-3333-3333-3333-333333333333",
1817                "max_attempts": 2,
1818            },
1819        }))
1820        .unwrap();
1821        let req = Request::from_json(&json).unwrap();
1822        assert!(
1823            req.job().is_none(),
1824            "missing attempt should yield None, not a partial JobContext"
1825        );
1826    }
1827
1828    #[test]
1829    fn test_response_json() {
1830        let resp = Response::json(&json!({"status": "ok", "count": 42}));
1831        let data = resp.into_data();
1832        let parsed: Value = serde_json::from_str(&data).unwrap();
1833        assert_eq!(parsed["status"], "ok");
1834        assert_eq!(parsed["count"], 42);
1835    }
1836
1837    #[test]
1838    fn test_response_error() {
1839        let resp = Response::error("something went wrong");
1840        let data = resp.into_data();
1841        let parsed: Value = serde_json::from_str(&data).unwrap();
1842        // error() returns status 400, so into_data wraps with __status/__body
1843        assert_eq!(parsed["__status"], 400);
1844        assert_eq!(parsed["__body"]["error"], "something went wrong");
1845    }
1846
1847    #[test]
1848    fn test_response_not_found() {
1849        let resp = Response::not_found("item not found");
1850        let data = resp.into_data();
1851        let parsed: Value = serde_json::from_str(&data).unwrap();
1852        assert_eq!(parsed["__status"], 404);
1853        assert_eq!(parsed["__body"]["error"], "item not found");
1854    }
1855
1856    #[test]
1857    fn test_response_with_status() {
1858        let resp = Response::json(&serde_json::json!({"ok": true})).with_status(201);
1859        let data = resp.into_data();
1860        let parsed: Value = serde_json::from_str(&data).unwrap();
1861        assert_eq!(parsed["__status"], 201);
1862        assert_eq!(parsed["__body"]["ok"], true);
1863    }
1864
1865    fn migrate_request(diff: serde_json::Value) -> Request {
1866        let payload = serde_json::to_string(&json!({
1867            "method": "POST",
1868            "handler": "handle_on_migrate",
1869            "headers": {},
1870            "body": diff,
1871            "tenant": "default",
1872            "service": "logistics-service",
1873        }))
1874        .unwrap();
1875        Request::from_json(&payload).unwrap()
1876    }
1877
1878    #[test]
1879    fn test_migrate_run_success() {
1880        let req = migrate_request(json!({
1881            "added_columns": [["pickups", "min"]],
1882            "dropped_columns": [["pickups", "midpoint"]],
1883        }));
1884        let resp = migrate::run(req, |diff| {
1885            assert!(diff.added_column("pickups", "min"));
1886            assert!(diff.dropped_column("pickups", "midpoint"));
1887            Ok(())
1888        });
1889        let parsed: Value = serde_json::from_str(&resp.into_data()).unwrap();
1890        assert_eq!(parsed["ok"], true);
1891    }
1892
1893    #[test]
1894    fn test_migrate_run_handler_error() {
1895        let req = migrate_request(json!({}));
1896        let resp = migrate::run(req, |_| Err("backfill failed".into()));
1897        let parsed: Value = serde_json::from_str(&resp.into_data()).unwrap();
1898        assert_eq!(parsed["__status"], 400);
1899        assert_eq!(parsed["__body"]["error"], "backfill failed");
1900    }
1901
1902    #[test]
1903    fn test_migrate_run_invalid_payload() {
1904        // body is a string, not a SchemaDiff object
1905        let req = migrate_request(json!("not a diff"));
1906        let resp = migrate::run(req, |_| {
1907            panic!("closure should not be called for invalid payload")
1908        });
1909        let parsed: Value = serde_json::from_str(&resp.into_data()).unwrap();
1910        assert_eq!(parsed["__status"], 400);
1911        assert!(parsed["__body"]["error"]
1912            .as_str()
1913            .unwrap()
1914            .contains("on_migrate: failed to parse SchemaDiff payload"));
1915    }
1916
1917    #[test]
1918    fn test_migrate_run_empty_diff() {
1919        let req = migrate_request(json!({}));
1920        let mut called = false;
1921        let resp = migrate::run(req, |diff| {
1922            assert!(diff.is_empty());
1923            called = true;
1924            Ok(())
1925        });
1926        assert!(called);
1927        let parsed: Value = serde_json::from_str(&resp.into_data()).unwrap();
1928        assert_eq!(parsed["ok"], true);
1929    }
1930
1931    #[test]
1932    fn test_response_200_no_wrapper() {
1933        let resp = Response::json(&serde_json::json!({"data": "test"}));
1934        let data = resp.into_data();
1935        let parsed: Value = serde_json::from_str(&data).unwrap();
1936        // 200 responses should NOT be wrapped
1937        assert_eq!(parsed["data"], "test");
1938        assert!(parsed.get("__status").is_none());
1939    }
1940
1941    #[test]
1942    fn test_response_empty() {
1943        let resp = Response::empty();
1944        let data = resp.into_data();
1945        let parsed: Value = serde_json::from_str(&data).unwrap();
1946        assert_eq!(parsed["ok"], true);
1947    }
1948
1949    #[test]
1950    fn test_response_text() {
1951        let resp = Response::text("hello world");
1952        let data = resp.into_data();
1953        let parsed: Value = serde_json::from_str(&data).unwrap();
1954        assert_eq!(parsed, "hello world");
1955    }
1956
1957    #[test]
1958    fn test_db_query_noop_on_native() {
1959        // On native (non-wasm) targets, db functions are no-ops
1960        let rows = db::query("SELECT 1");
1961        assert!(rows.is_empty());
1962    }
1963
1964    #[test]
1965    fn test_db_query_one_noop_on_native() {
1966        let row = db::query_one("SELECT 1");
1967        assert!(row.is_none());
1968    }
1969
1970    #[test]
1971    fn test_db_execute_noop_on_native() {
1972        let affected = db::execute("INSERT INTO x VALUES (1)");
1973        assert_eq!(affected, 0);
1974    }
1975
1976    #[test]
1977    fn test_nats_publish_noop_on_native() {
1978        let ok = nats::publish("test.subject", "payload");
1979        assert!(ok);
1980    }
1981
1982    #[test]
1983    fn test_request_with_auth() {
1984        let json = serde_json::to_string(&json!({
1985            "method": "POST",
1986            "handler": "checkout",
1987            "headers": {},
1988            "body": {},
1989            "tenant": "acme",
1990            "service": "shop",
1991            "auth": {
1992                "sub": "user-123",
1993                "preferred_username": "john",
1994                "name": "John Doe",
1995                "email": "john@example.com",
1996                "realm_roles": ["admin", "manager"],
1997                "claims": {"department": "engineering"}
1998            }
1999        }))
2000        .unwrap();
2001
2002        let req = Request::from_json(&json).unwrap();
2003        let auth = req.auth().unwrap();
2004        assert_eq!(auth.sub, "user-123");
2005        assert_eq!(auth.preferred_username.as_deref(), Some("john"));
2006        assert_eq!(auth.name.as_deref(), Some("John Doe"));
2007        assert_eq!(auth.email.as_deref(), Some("john@example.com"));
2008        assert!(auth.has_role("admin"));
2009        assert!(auth.has_role("manager"));
2010        assert!(!auth.has_role("viewer"));
2011        assert_eq!(
2012            auth.claim("department").and_then(|v| v.as_str()),
2013            Some("engineering")
2014        );
2015    }
2016
2017    #[test]
2018    fn test_request_without_auth() {
2019        let json = r#"{"method": "GET"}"#;
2020        let req = Request::from_json(json).unwrap();
2021        assert!(req.auth().is_none());
2022    }
2023
2024    #[test]
2025    fn test_request_null_auth() {
2026        let json = serde_json::to_string(&json!({
2027            "method": "GET",
2028            "auth": null
2029        }))
2030        .unwrap();
2031        let req = Request::from_json(&json).unwrap();
2032        assert!(req.auth().is_none());
2033    }
2034
2035    #[test]
2036    fn test_require_auth_success() {
2037        let json = serde_json::to_string(&json!({
2038            "method": "GET",
2039            "auth": {"sub": "user-1", "realm_roles": [], "claims": {}}
2040        }))
2041        .unwrap();
2042        let req = Request::from_json(&json).unwrap();
2043        assert!(req.require_auth().is_ok());
2044        assert_eq!(req.require_auth().unwrap().sub, "user-1");
2045    }
2046
2047    #[test]
2048    fn test_require_auth_fails_when_unauthenticated() {
2049        let json = r#"{"method": "GET"}"#;
2050        let req = Request::from_json(json).unwrap();
2051        assert!(req.require_auth().is_err());
2052    }
2053
2054    #[test]
2055    fn test_http_fetch_noop_on_native() {
2056        let resp = http::fetch("GET", "https://example.com", &[], None);
2057        assert!(resp.is_none());
2058    }
2059
2060    #[test]
2061    fn test_http_get_noop_on_native() {
2062        let resp = http::get("https://example.com", &[]);
2063        assert!(resp.is_none());
2064    }
2065
2066    #[test]
2067    fn test_http_post_noop_on_native() {
2068        let resp = http::post("https://example.com", &[], "{}");
2069        assert!(resp.is_none());
2070    }
2071
2072    #[test]
2073    fn test_storage_download_noop_on_native() {
2074        let data = storage::download("my-bucket", "images/photo.jpg");
2075        assert!(data.is_none());
2076    }
2077
2078    #[test]
2079    fn test_image_transform_jpeg_noop_on_native() {
2080        let bytes = image::transform_jpeg("my-bucket", "in.jpg", "out.jpg", 1024, 80);
2081        assert!(bytes.is_none());
2082    }
2083
2084    #[test]
2085    fn test_auth_permissions() {
2086        let json = serde_json::to_string(&json!({
2087            "method": "POST",
2088            "handler": "test",
2089            "headers": {},
2090            "body": {},
2091            "tenant": "acme",
2092            "service": "shop",
2093            "auth": {
2094                "sub": "user-1",
2095                "realm_roles": ["admin"],
2096                "claims": {},
2097                "permissions": ["staff:create", "staff:view", "items:*"],
2098                "role_names": ["admin", "manager"]
2099            }
2100        }))
2101        .unwrap();
2102
2103        let req = Request::from_json(&json).unwrap();
2104        let auth = req.auth().unwrap();
2105
2106        // Exact permission match
2107        assert!(auth.can("staff", "create"));
2108        assert!(auth.can("staff", "view"));
2109        assert!(!auth.can("staff", "delete"));
2110
2111        // Wildcard match
2112        assert!(auth.can("items", "create"));
2113        assert!(auth.can("items", "view"));
2114        assert!(auth.can("items", "delete"));
2115
2116        // No match
2117        assert!(!auth.can("batches", "view"));
2118
2119        // Cufflink roles
2120        assert!(auth.has_cufflink_role("admin"));
2121        assert!(auth.has_cufflink_role("manager"));
2122        assert!(!auth.has_cufflink_role("viewer"));
2123    }
2124
2125    #[test]
2126    fn test_auth_super_wildcard() {
2127        let auth = Auth {
2128            sub: "user-1".to_string(),
2129            preferred_username: None,
2130            name: None,
2131            email: None,
2132            realm_roles: vec![],
2133            claims: HashMap::new(),
2134            permissions: vec!["*".to_string()],
2135            role_names: vec!["superadmin".to_string()],
2136            is_service_account: false,
2137        };
2138
2139        assert!(auth.can("anything", "everything"));
2140        assert!(auth.can("staff", "create"));
2141    }
2142
2143    #[test]
2144    fn test_auth_empty_permissions() {
2145        let auth = Auth {
2146            sub: "user-1".to_string(),
2147            preferred_username: None,
2148            name: None,
2149            email: None,
2150            realm_roles: vec![],
2151            claims: HashMap::new(),
2152            permissions: vec![],
2153            role_names: vec![],
2154            is_service_account: false,
2155        };
2156
2157        assert!(!auth.can("staff", "create"));
2158        assert!(!auth.has_cufflink_role("admin"));
2159    }
2160
2161    #[test]
2162    fn test_redis_get_noop_on_native() {
2163        let val = redis::get("some-key");
2164        assert!(val.is_none());
2165    }
2166
2167    #[test]
2168    fn test_redis_set_noop_on_native() {
2169        let ok = redis::set("key", "value", 3600);
2170        assert!(ok);
2171    }
2172
2173    #[test]
2174    fn test_redis_del_noop_on_native() {
2175        let ok = redis::del("key");
2176        assert!(ok);
2177    }
2178
2179    #[test]
2180    fn test_http_fetch_response_helpers() {
2181        let resp = http::FetchResponse {
2182            status: 200,
2183            body: r#"{"key": "value"}"#.to_string(),
2184            body_encoding: "utf8".to_string(),
2185            headers: HashMap::new(),
2186        };
2187        assert!(resp.is_success());
2188        assert!(!resp.is_base64());
2189        let json = resp.json().unwrap();
2190        assert_eq!(json["key"], "value");
2191
2192        let err_resp = http::FetchResponse {
2193            status: 404,
2194            body: "not found".to_string(),
2195            body_encoding: "utf8".to_string(),
2196            headers: HashMap::new(),
2197        };
2198        assert!(!err_resp.is_success());
2199
2200        let binary_resp = http::FetchResponse {
2201            status: 200,
2202            body: "aW1hZ2VkYXRh".to_string(),
2203            body_encoding: "base64".to_string(),
2204            headers: HashMap::new(),
2205        };
2206        assert!(binary_resp.is_base64());
2207    }
2208
2209    #[test]
2210    fn context_tenant_returns_empty_outside_wasm() {
2211        assert_eq!(context::tenant(), "");
2212    }
2213
2214    #[test]
2215    fn redis_get_with_status_returns_none_outside_wasm() {
2216        assert_eq!(redis::get_with_status("any"), Ok(None));
2217    }
2218
2219    #[test]
2220    fn redis_mget_returns_empty_for_empty_input() {
2221        assert_eq!(redis::mget(&[]), Ok(Vec::new()));
2222    }
2223
2224    #[test]
2225    fn redis_mget_returns_misses_outside_wasm() {
2226        assert_eq!(redis::mget(&["a", "b", "c"]), Ok(vec![None, None, None]));
2227    }
2228}