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// ─── Raw FFI ─────────────────────────────────────────────────────────────────
34// These are the host functions provided by the Cufflink platform WASM runtime.
35// Users never call these directly — use the `db`, `nats`, and `log` modules.
36
37#[cfg(target_arch = "wasm32")]
38extern "C" {
39    #[link_name = "cufflink_log"]
40    fn cufflink_log(level: i32, msg_ptr: i32, msg_len: i32);
41    fn db_query(sql_ptr: i32, sql_len: i32) -> i32;
42    fn db_execute(sql_ptr: i32, sql_len: i32) -> i32;
43    fn get_host_response_len() -> i32;
44    fn get_host_response(buf_ptr: i32, buf_len: i32) -> i32;
45    fn nats_publish(subj_ptr: i32, subj_len: i32, payload_ptr: i32, payload_len: i32) -> i32;
46    fn nats_request(
47        subj_ptr: i32,
48        subj_len: i32,
49        payload_ptr: i32,
50        payload_len: i32,
51        timeout_ms: i32,
52    ) -> i32;
53    fn http_fetch(
54        method_ptr: i32,
55        method_len: i32,
56        url_ptr: i32,
57        url_len: i32,
58        headers_ptr: i32,
59        headers_len: i32,
60        body_ptr: i32,
61        body_len: i32,
62    ) -> i32;
63    fn get_config(key_ptr: i32, key_len: i32) -> i32;
64    fn s3_download(bucket_ptr: i32, bucket_len: i32, key_ptr: i32, key_len: i32) -> i32;
65    fn s3_presign_upload(
66        bucket_ptr: i32,
67        bucket_len: i32,
68        key_ptr: i32,
69        key_len: i32,
70        content_type_ptr: i32,
71        content_type_len: i32,
72        expires_secs: i32,
73    ) -> i32;
74    fn redis_get(key_ptr: i32, key_len: i32) -> i32;
75    fn redis_set(key_ptr: i32, key_len: i32, val_ptr: i32, val_len: i32, ttl_secs: i32) -> i32;
76    fn redis_del(key_ptr: i32, key_len: i32) -> i32;
77    fn generate_uuid() -> i32;
78    fn current_time() -> i32;
79}
80
81// ─── Auth ────────────────────────────────────────────────────────────────────
82
83/// Authenticated user context, validated by the Cufflink platform.
84///
85/// The platform validates the JWT token (via Keycloak) and extracts claims
86/// before passing them to your handler. You never need to validate tokens
87/// yourself — the `auth` field is only present when the token is valid.
88///
89/// ```rust,ignore
90/// handler!(protected, |req: Request| {
91///     let auth = match req.require_auth() {
92///         Ok(auth) => auth,
93///         Err(resp) => return resp,
94///     };
95///     if !auth.has_role("admin") {
96///         return Response::error("Forbidden");
97///     }
98///     Response::json(&json!({"user": auth.sub}))
99/// });
100/// ```
101#[derive(Debug, Clone)]
102pub struct Auth {
103    /// Keycloak subject ID (unique user identifier).
104    pub sub: String,
105    /// Preferred username from Keycloak.
106    pub preferred_username: Option<String>,
107    /// Display name.
108    pub name: Option<String>,
109    /// Email address.
110    pub email: Option<String>,
111    /// Realm roles assigned to the user in Keycloak.
112    pub realm_roles: Vec<String>,
113    /// All other JWT claims (custom Keycloak mappers, resource_access, etc.).
114    pub claims: HashMap<String, Value>,
115    /// Cufflink permissions resolved from the service's tenant roles (e.g., `["staff:create", "items:*"]`).
116    pub permissions: Vec<String>,
117    /// Cufflink role names assigned to the user (e.g., `["admin", "manager"]`).
118    pub role_names: Vec<String>,
119    /// Whether this is a Keycloak service account (client credentials grant).
120    /// Service accounts bypass permission checks at the platform level.
121    pub is_service_account: bool,
122}
123
124impl Auth {
125    /// Check if the user has a specific Keycloak realm role.
126    pub fn has_role(&self, role: &str) -> bool {
127        self.realm_roles.iter().any(|r| r == role)
128    }
129
130    /// Check if the user has a specific Cufflink permission.
131    ///
132    /// Supports wildcards: `"staff:*"` matches any operation in the "staff" area,
133    /// and `"*"` matches everything.
134    ///
135    /// ```rust,ignore
136    /// if !auth.can("staff", "create") {
137    ///     return Response::error("Forbidden: missing staff:create permission");
138    /// }
139    /// ```
140    pub fn can(&self, area: &str, operation: &str) -> bool {
141        let required = format!("{}:{}", area, operation);
142        let wildcard = format!("{}:*", area);
143        self.permissions
144            .iter()
145            .any(|p| p == &required || p == &wildcard || p == "*")
146    }
147
148    /// Check if the user has a specific Cufflink role (by name).
149    pub fn has_cufflink_role(&self, role: &str) -> bool {
150        self.role_names.iter().any(|r| r == role)
151    }
152
153    /// Get a specific claim value by key.
154    pub fn claim(&self, key: &str) -> Option<&Value> {
155        self.claims.get(key)
156    }
157}
158
159// ─── Request ─────────────────────────────────────────────────────────────────
160
161/// An incoming HTTP request from the Cufflink platform.
162///
163/// The platform serializes the full request context (method, headers, body,
164/// tenant, service name, auth) into JSON and passes it to your handler.
165#[derive(Debug, Clone)]
166pub struct Request {
167    method: String,
168    handler: String,
169    headers: HashMap<String, String>,
170    body: Value,
171    tenant: String,
172    service: String,
173    auth: Option<Auth>,
174}
175
176impl Request {
177    /// Parse a `Request` from the JSON the platform provides.
178    pub fn from_json(json: &str) -> Option<Self> {
179        let v: Value = serde_json::from_str(json).ok()?;
180        let headers = v["headers"]
181            .as_object()
182            .map(|m| {
183                m.iter()
184                    .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
185                    .collect()
186            })
187            .unwrap_or_default();
188
189        let auth = v["auth"].as_object().map(|auth_obj| {
190            let a = Value::Object(auth_obj.clone());
191            Auth {
192                sub: a["sub"].as_str().unwrap_or("").to_string(),
193                preferred_username: a["preferred_username"].as_str().map(|s| s.to_string()),
194                name: a["name"].as_str().map(|s| s.to_string()),
195                email: a["email"].as_str().map(|s| s.to_string()),
196                realm_roles: a["realm_roles"]
197                    .as_array()
198                    .map(|arr| {
199                        arr.iter()
200                            .filter_map(|v| v.as_str().map(|s| s.to_string()))
201                            .collect()
202                    })
203                    .unwrap_or_default(),
204                claims: a["claims"]
205                    .as_object()
206                    .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
207                    .unwrap_or_default(),
208                permissions: a["permissions"]
209                    .as_array()
210                    .map(|arr| {
211                        arr.iter()
212                            .filter_map(|v| v.as_str().map(|s| s.to_string()))
213                            .collect()
214                    })
215                    .unwrap_or_default(),
216                role_names: a["role_names"]
217                    .as_array()
218                    .map(|arr| {
219                        arr.iter()
220                            .filter_map(|v| v.as_str().map(|s| s.to_string()))
221                            .collect()
222                    })
223                    .unwrap_or_default(),
224                is_service_account: a["is_service_account"].as_bool().unwrap_or(false),
225            }
226        });
227
228        Some(Self {
229            method: v["method"].as_str().unwrap_or("GET").to_string(),
230            handler: v["handler"].as_str().unwrap_or("").to_string(),
231            headers,
232            body: v["body"].clone(),
233            tenant: v["tenant"].as_str().unwrap_or("").to_string(),
234            service: v["service"].as_str().unwrap_or("").to_string(),
235            auth,
236        })
237    }
238
239    /// The HTTP method (GET, POST, PUT, DELETE).
240    pub fn method(&self) -> &str {
241        &self.method
242    }
243
244    /// The handler name from the URL path.
245    pub fn handler(&self) -> &str {
246        &self.handler
247    }
248
249    /// All HTTP headers as a map.
250    pub fn headers(&self) -> &HashMap<String, String> {
251        &self.headers
252    }
253
254    /// Get a specific header value.
255    pub fn header(&self, name: &str) -> Option<&str> {
256        self.headers.get(name).map(|s| s.as_str())
257    }
258
259    /// The parsed JSON body. Returns `Value::Null` if no body was sent.
260    pub fn body(&self) -> &Value {
261        &self.body
262    }
263
264    /// The tenant slug from the URL.
265    pub fn tenant(&self) -> &str {
266        &self.tenant
267    }
268
269    /// The service name from the URL.
270    pub fn service(&self) -> &str {
271        &self.service
272    }
273
274    /// Get the authenticated user context, if present.
275    ///
276    /// Returns `None` if no valid JWT or API key was provided with the request.
277    pub fn auth(&self) -> Option<&Auth> {
278        self.auth.as_ref()
279    }
280
281    /// Require authentication. Returns the auth context or an error response.
282    ///
283    /// ```rust,ignore
284    /// handler!(protected, |req: Request| {
285    ///     let auth = match req.require_auth() {
286    ///         Ok(auth) => auth,
287    ///         Err(resp) => return resp,
288    ///     };
289    ///     Response::json(&json!({"user": auth.sub}))
290    /// });
291    /// ```
292    pub fn require_auth(&self) -> Result<&Auth, Response> {
293        self.auth.as_ref().ok_or_else(|| {
294            Response::json(&serde_json::json!({
295                "error": "Authentication required",
296                "status": 401
297            }))
298        })
299    }
300}
301
302// ─── Response ────────────────────────────────────────────────────────────────
303
304/// An HTTP response to return from your handler.
305#[derive(Debug, Clone)]
306pub struct Response {
307    data: String,
308    status: u16,
309}
310
311impl Response {
312    /// Return a JSON response with HTTP 200.
313    pub fn json(value: &Value) -> Self {
314        Self {
315            data: serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string()),
316            status: 200,
317        }
318    }
319
320    /// Return a plain text response (wrapped in a JSON string).
321    pub fn text(s: &str) -> Self {
322        Self::json(&Value::String(s.to_string()))
323    }
324
325    /// Return an error response with HTTP 400.
326    pub fn error(message: &str) -> Self {
327        Self {
328            data: serde_json::json!({"error": message}).to_string(),
329            status: 400,
330        }
331    }
332
333    /// Return a 404 Not Found error.
334    pub fn not_found(message: &str) -> Self {
335        Self {
336            data: serde_json::json!({"error": message}).to_string(),
337            status: 404,
338        }
339    }
340
341    /// Return a 403 Forbidden error.
342    pub fn forbidden(message: &str) -> Self {
343        Self {
344            data: serde_json::json!({"error": message}).to_string(),
345            status: 403,
346        }
347    }
348
349    /// Return an empty success response.
350    pub fn empty() -> Self {
351        Self::json(&serde_json::json!({"ok": true}))
352    }
353
354    /// Set a custom HTTP status code on the response.
355    pub fn with_status(mut self, status: u16) -> Self {
356        self.status = status;
357        self
358    }
359
360    /// Get the raw response string.
361    /// Encodes the status code into the response so the platform can extract it.
362    pub fn into_data(self) -> String {
363        if self.status == 200 {
364            // No wrapping needed for 200 — backwards compatible
365            self.data
366        } else {
367            // Wrap with __status so the platform can set the HTTP status code
368            serde_json::json!({
369                "__status": self.status,
370                "__body": serde_json::from_str::<Value>(&self.data).unwrap_or(Value::String(self.data)),
371            })
372            .to_string()
373        }
374    }
375}
376
377// ─── db module ───────────────────────────────────────────────────────────────
378
379/// Database access — run SQL queries against your service's tables.
380///
381/// All queries run in the tenant's schema automatically. You don't need
382/// to qualify table names with a schema prefix.
383pub mod db {
384    use super::*;
385
386    /// Run a SELECT query and return all rows as a `Vec<Value>`.
387    ///
388    /// Each row is a JSON object with column names as keys.
389    ///
390    /// ```rust,ignore
391    /// let users = db::query("SELECT id, name, email FROM users WHERE active = true");
392    /// for user in &users {
393    ///     log::info(&format!("User: {}", user["name"]));
394    /// }
395    /// ```
396    pub fn query(sql: &str) -> Vec<Value> {
397        #[cfg(target_arch = "wasm32")]
398        {
399            let bytes = sql.as_bytes();
400            let result = unsafe { db_query(bytes.as_ptr() as i32, bytes.len() as i32) };
401            if result < 0 {
402                return vec![];
403            }
404            read_host_response()
405        }
406        #[cfg(not(target_arch = "wasm32"))]
407        {
408            let _ = sql;
409            vec![]
410        }
411    }
412
413    /// Run a SELECT query and return the first row, or `None` if empty.
414    ///
415    /// ```rust,ignore
416    /// if let Some(user) = db::query_one("SELECT * FROM users WHERE id = 'abc'") {
417    ///     log::info(&format!("Found user: {}", user["name"]));
418    /// }
419    /// ```
420    pub fn query_one(sql: &str) -> Option<Value> {
421        query(sql).into_iter().next()
422    }
423
424    /// Run an INSERT, UPDATE, or DELETE statement.
425    ///
426    /// Returns the number of affected rows, or -1 on error.
427    ///
428    /// ```rust,ignore
429    /// let affected = db::execute("UPDATE orders SET status = 'shipped' WHERE id = 'abc'");
430    /// log::info(&format!("Updated {} rows", affected));
431    /// ```
432    pub fn execute(sql: &str) -> i32 {
433        #[cfg(target_arch = "wasm32")]
434        {
435            let bytes = sql.as_bytes();
436            unsafe { db_execute(bytes.as_ptr() as i32, bytes.len() as i32) }
437        }
438        #[cfg(not(target_arch = "wasm32"))]
439        {
440            let _ = sql;
441            0
442        }
443    }
444
445    /// Read the host response buffer (used internally after db_query).
446    #[cfg(target_arch = "wasm32")]
447    fn read_host_response() -> Vec<Value> {
448        let len = unsafe { get_host_response_len() };
449        if len <= 0 {
450            return vec![];
451        }
452        let mut buf = vec![0u8; len as usize];
453        let read = unsafe { get_host_response(buf.as_mut_ptr() as i32, len) };
454        if read <= 0 {
455            return vec![];
456        }
457        buf.truncate(read as usize);
458        let json_str = String::from_utf8_lossy(&buf);
459        serde_json::from_str(&json_str).unwrap_or_default()
460    }
461}
462
463// ─── nats module ─────────────────────────────────────────────────────────────
464
465/// Publish messages to NATS for event-driven communication.
466///
467/// Use this to notify other services, trigger subscriptions, or emit
468/// domain events.
469pub mod nats {
470    #[allow(unused_imports)]
471    use super::*;
472
473    /// Publish a message to a NATS subject.
474    ///
475    /// Returns `true` on success, `false` on failure.
476    ///
477    /// ```rust,ignore
478    /// nats::publish(
479    ///     "dw.acme.order-service.orders.created",
480    ///     &serde_json::json!({"order_id": "abc", "total": 4500}).to_string(),
481    /// );
482    /// ```
483    pub fn publish(subject: &str, payload: &str) -> bool {
484        #[cfg(target_arch = "wasm32")]
485        {
486            let subj_bytes = subject.as_bytes();
487            let payload_bytes = payload.as_bytes();
488            let result = unsafe {
489                nats_publish(
490                    subj_bytes.as_ptr() as i32,
491                    subj_bytes.len() as i32,
492                    payload_bytes.as_ptr() as i32,
493                    payload_bytes.len() as i32,
494                )
495            };
496            result == 0
497        }
498        #[cfg(not(target_arch = "wasm32"))]
499        {
500            let _ = (subject, payload);
501            true
502        }
503    }
504
505    /// Send a NATS request and wait for a reply (synchronous request-reply).
506    ///
507    /// Returns the reply payload as a string, or `None` on timeout/failure.
508    ///
509    /// ```rust,ignore
510    /// let reply = nats::request(
511    ///     "dw.acme.user-service.users.lookup",
512    ///     &serde_json::json!({"customer_id": "abc"}).to_string(),
513    ///     5000, // timeout in ms
514    /// );
515    /// ```
516    pub fn request(subject: &str, payload: &str, timeout_ms: i32) -> Option<String> {
517        #[cfg(target_arch = "wasm32")]
518        {
519            let subj_bytes = subject.as_bytes();
520            let payload_bytes = payload.as_bytes();
521            let result = unsafe {
522                nats_request(
523                    subj_bytes.as_ptr() as i32,
524                    subj_bytes.len() as i32,
525                    payload_bytes.as_ptr() as i32,
526                    payload_bytes.len() as i32,
527                    timeout_ms,
528                )
529            };
530            if result != 0 {
531                return None;
532            }
533            let len = unsafe { get_host_response_len() };
534            if len <= 0 {
535                return None;
536            }
537            let mut buf = vec![0u8; len as usize];
538            let read = unsafe { get_host_response(buf.as_mut_ptr() as i32, len) };
539            if read <= 0 {
540                return None;
541            }
542            String::from_utf8(buf[..read as usize].to_vec()).ok()
543        }
544        #[cfg(not(target_arch = "wasm32"))]
545        {
546            let _ = (subject, payload, timeout_ms);
547            None
548        }
549    }
550}
551
552// ─── log module ──────────────────────────────────────────────────────────────
553
554/// Structured logging from inside your WASM handler.
555///
556/// Messages appear in the platform's log output prefixed with `[wasm]`.
557pub mod log {
558    #[allow(unused_imports)]
559    use super::*;
560
561    /// Log an error message (level 0).
562    pub fn error(msg: &str) {
563        write(0, msg);
564    }
565
566    /// Log a warning message (level 1).
567    pub fn warn(msg: &str) {
568        write(1, msg);
569    }
570
571    /// Log an info message (level 2).
572    pub fn info(msg: &str) {
573        write(2, msg);
574    }
575
576    /// Log a debug message (level 3).
577    pub fn debug(msg: &str) {
578        write(3, msg);
579    }
580
581    fn write(level: i32, msg: &str) {
582        #[cfg(target_arch = "wasm32")]
583        {
584            let bytes = msg.as_bytes();
585            unsafe {
586                super::cufflink_log(level, bytes.as_ptr() as i32, bytes.len() as i32);
587            }
588        }
589        #[cfg(not(target_arch = "wasm32"))]
590        {
591            let _ = (level, msg);
592        }
593    }
594}
595
596// ─── http module ────────────────────────────────────────────────────────────
597
598/// Make HTTP requests from inside your WASM handler.
599///
600/// Use this to call external APIs (Keycloak admin, third-party services, etc.)
601/// from your handler code.
602pub mod http {
603    #[allow(unused_imports)]
604    use super::*;
605
606    /// Response from an HTTP request.
607    #[derive(Debug, Clone)]
608    pub struct FetchResponse {
609        /// HTTP status code (e.g., 200, 404, 500).
610        pub status: i32,
611        /// Response body as a string (may be base64-encoded for binary content).
612        pub body: String,
613        /// Body encoding: "utf8" for text, "base64" for binary content.
614        pub body_encoding: String,
615        /// Response headers.
616        pub headers: HashMap<String, String>,
617    }
618
619    impl FetchResponse {
620        /// Parse the response body as JSON.
621        pub fn json(&self) -> Option<Value> {
622            serde_json::from_str(&self.body).ok()
623        }
624
625        /// Check if the response status indicates success (2xx).
626        pub fn is_success(&self) -> bool {
627            (200..300).contains(&self.status)
628        }
629
630        /// Check if the body is base64-encoded (binary content).
631        pub fn is_base64(&self) -> bool {
632            self.body_encoding == "base64"
633        }
634    }
635
636    /// Make an HTTP request.
637    ///
638    /// ```rust,ignore
639    /// let resp = http::fetch("GET", "https://api.example.com/data", &[], None);
640    /// if let Some(resp) = resp {
641    ///     if resp.is_success() {
642    ///         log::info(&format!("Got: {}", resp.body));
643    ///     }
644    /// }
645    /// ```
646    pub fn fetch(
647        method: &str,
648        url: &str,
649        headers: &[(&str, &str)],
650        body: Option<&str>,
651    ) -> Option<FetchResponse> {
652        #[cfg(target_arch = "wasm32")]
653        {
654            let method_bytes = method.as_bytes();
655            let url_bytes = url.as_bytes();
656            let headers_map: HashMap<&str, &str> = headers.iter().copied().collect();
657            let headers_json = serde_json::to_string(&headers_map).unwrap_or_default();
658            let headers_bytes = headers_json.as_bytes();
659            let body_bytes = body.unwrap_or("").as_bytes();
660            let body_len = body.map(|b| b.len()).unwrap_or(0);
661
662            let result = unsafe {
663                http_fetch(
664                    method_bytes.as_ptr() as i32,
665                    method_bytes.len() as i32,
666                    url_bytes.as_ptr() as i32,
667                    url_bytes.len() as i32,
668                    headers_bytes.as_ptr() as i32,
669                    headers_bytes.len() as i32,
670                    body_bytes.as_ptr() as i32,
671                    body_len as i32,
672                )
673            };
674
675            if result < 0 {
676                return None;
677            }
678
679            read_fetch_response()
680        }
681        #[cfg(not(target_arch = "wasm32"))]
682        {
683            let _ = (method, url, headers, body);
684            None
685        }
686    }
687
688    /// Make a GET request.
689    pub fn get(url: &str, headers: &[(&str, &str)]) -> Option<FetchResponse> {
690        fetch("GET", url, headers, None)
691    }
692
693    /// Make a POST request with a body.
694    pub fn post(url: &str, headers: &[(&str, &str)], body: &str) -> Option<FetchResponse> {
695        fetch("POST", url, headers, Some(body))
696    }
697
698    /// Make a PUT request with a body.
699    pub fn put(url: &str, headers: &[(&str, &str)], body: &str) -> Option<FetchResponse> {
700        fetch("PUT", url, headers, Some(body))
701    }
702
703    /// Make a DELETE request.
704    pub fn delete(url: &str, headers: &[(&str, &str)]) -> Option<FetchResponse> {
705        fetch("DELETE", url, headers, None)
706    }
707
708    /// Make a PATCH request with a body.
709    pub fn patch(url: &str, headers: &[(&str, &str)], body: &str) -> Option<FetchResponse> {
710        fetch("PATCH", url, headers, Some(body))
711    }
712
713    /// Read the host response buffer after http_fetch.
714    #[cfg(target_arch = "wasm32")]
715    fn read_fetch_response() -> Option<FetchResponse> {
716        let len = unsafe { get_host_response_len() };
717        if len <= 0 {
718            return None;
719        }
720        let mut buf = vec![0u8; len as usize];
721        let read = unsafe { get_host_response(buf.as_mut_ptr() as i32, len) };
722        if read <= 0 {
723            return None;
724        }
725        buf.truncate(read as usize);
726        let json_str = String::from_utf8_lossy(&buf);
727        let v: Value = serde_json::from_str(&json_str).ok()?;
728        Some(FetchResponse {
729            status: v["status"].as_i64().unwrap_or(0) as i32,
730            body: v["body"].as_str().unwrap_or("").to_string(),
731            body_encoding: v["body_encoding"].as_str().unwrap_or("utf8").to_string(),
732            headers: v["headers"]
733                .as_object()
734                .map(|m| {
735                    m.iter()
736                        .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
737                        .collect()
738                })
739                .unwrap_or_default(),
740        })
741    }
742}
743
744// ─── config module ──────────────────────────────────────────────────────
745
746/// Read service configuration values set via `cufflink config set`.
747///
748/// Config values are stored in the platform's `service_configs` table,
749/// scoped to your service. Use `cufflink config set KEY VALUE [--secret]`
750/// to set values via the CLI.
751pub mod config {
752    #[allow(unused_imports)]
753    use super::*;
754
755    /// Get a config value by key. Returns `None` if the key doesn't exist.
756    ///
757    /// ```rust,ignore
758    /// let api_key = config::get("ANTHROPIC_API_KEY");
759    /// if let Some(key) = api_key {
760    ///     log::info(&format!("API key loaded ({} chars)", key.len()));
761    /// }
762    /// ```
763    pub fn get(key: &str) -> Option<String> {
764        #[cfg(target_arch = "wasm32")]
765        {
766            let bytes = key.as_bytes();
767            let result = unsafe { get_config(bytes.as_ptr() as i32, bytes.len() as i32) };
768            if result < 0 {
769                return None;
770            }
771            read_config_response()
772        }
773        #[cfg(not(target_arch = "wasm32"))]
774        {
775            let _ = key;
776            None
777        }
778    }
779
780    /// Read the host response buffer after get_config.
781    #[cfg(target_arch = "wasm32")]
782    fn read_config_response() -> Option<String> {
783        let len = unsafe { get_host_response_len() };
784        if len <= 0 {
785            return None;
786        }
787        let mut buf = vec![0u8; len as usize];
788        let read = unsafe { get_host_response(buf.as_mut_ptr() as i32, len) };
789        if read <= 0 {
790            return None;
791        }
792        buf.truncate(read as usize);
793        String::from_utf8(buf).ok()
794    }
795}
796
797// ─── storage module ─────────────────────────────────────────────────────
798
799/// Download files from S3-compatible object storage using the platform's credentials.
800///
801/// The platform uses its own S3 credentials (configured at deployment time) to
802/// perform authenticated downloads. This works with AWS S3, Hetzner Object Storage,
803/// MinIO, and any S3-compatible service.
804pub mod storage {
805    #[allow(unused_imports)]
806    use super::*;
807
808    /// Download a file from S3 and return its contents as a base64-encoded string.
809    ///
810    /// Returns `None` if the download fails (bucket not found, key not found,
811    /// S3 not configured, etc.).
812    ///
813    /// ```rust,ignore
814    /// if let Some(base64_data) = storage::download("my-bucket", "images/photo.jpg") {
815    ///     log::info(&format!("Downloaded {} bytes of base64", base64_data.len()));
816    /// }
817    /// ```
818    pub fn download(bucket: &str, key: &str) -> Option<String> {
819        #[cfg(target_arch = "wasm32")]
820        {
821            let bucket_bytes = bucket.as_bytes();
822            let key_bytes = key.as_bytes();
823            let result = unsafe {
824                s3_download(
825                    bucket_bytes.as_ptr() as i32,
826                    bucket_bytes.len() as i32,
827                    key_bytes.as_ptr() as i32,
828                    key_bytes.len() as i32,
829                )
830            };
831            if result < 0 {
832                return None;
833            }
834            read_storage_response()
835        }
836        #[cfg(not(target_arch = "wasm32"))]
837        {
838            let _ = (bucket, key);
839            None
840        }
841    }
842
843    /// Generate a presigned PUT URL for uploading a file directly to S3.
844    ///
845    /// The returned URL is valid for `expires_secs` seconds and allows
846    /// unauthenticated PUT requests. Clients can upload by sending a PUT
847    /// request to the URL with the file data as the body.
848    ///
849    /// ```rust,ignore
850    /// if let Some(url) = storage::presign_upload("my-bucket", "uploads/photo.jpg", "image/jpeg", 300) {
851    ///     // Return this URL to the client for direct upload
852    ///     log::info(&format!("Upload URL: {}", url));
853    /// }
854    /// ```
855    pub fn presign_upload(
856        bucket: &str,
857        key: &str,
858        content_type: &str,
859        expires_secs: u64,
860    ) -> Option<String> {
861        #[cfg(target_arch = "wasm32")]
862        {
863            let bucket_bytes = bucket.as_bytes();
864            let key_bytes = key.as_bytes();
865            let ct_bytes = content_type.as_bytes();
866            let result = unsafe {
867                s3_presign_upload(
868                    bucket_bytes.as_ptr() as i32,
869                    bucket_bytes.len() as i32,
870                    key_bytes.as_ptr() as i32,
871                    key_bytes.len() as i32,
872                    ct_bytes.as_ptr() as i32,
873                    ct_bytes.len() as i32,
874                    expires_secs as i32,
875                )
876            };
877            if result < 0 {
878                return None;
879            }
880            read_storage_response()
881        }
882        #[cfg(not(target_arch = "wasm32"))]
883        {
884            let _ = (bucket, key, content_type, expires_secs);
885            None
886        }
887    }
888
889    /// Read the host response buffer after s3_download or s3_presign_upload.
890    #[cfg(target_arch = "wasm32")]
891    fn read_storage_response() -> Option<String> {
892        let len = unsafe { get_host_response_len() };
893        if len <= 0 {
894            return None;
895        }
896        let mut buf = vec![0u8; len as usize];
897        let read = unsafe { get_host_response(buf.as_mut_ptr() as i32, len) };
898        if read <= 0 {
899            return None;
900        }
901        buf.truncate(read as usize);
902        String::from_utf8(buf).ok()
903    }
904}
905
906// ─── redis module ────────────────────────────────────────────────────────
907
908/// Read and write values in Redis (backed by the platform's Redis connection).
909///
910/// Use this for caching, session storage, or any key-value data that needs
911/// to be shared across services or requests with low latency.
912pub mod redis {
913    #[allow(unused_imports)]
914    use super::*;
915
916    /// Get a value from Redis by key. Returns `None` if the key doesn't exist
917    /// or Redis is not configured.
918    ///
919    /// ```rust,ignore
920    /// if let Some(cached) = redis::get("auth:perms:user-123") {
921    ///     log::info(&format!("Cache hit: {}", cached));
922    /// }
923    /// ```
924    pub fn get(key: &str) -> Option<String> {
925        #[cfg(target_arch = "wasm32")]
926        {
927            let bytes = key.as_bytes();
928            let result = unsafe { redis_get(bytes.as_ptr() as i32, bytes.len() as i32) };
929            if result < 0 {
930                return None;
931            }
932            read_redis_response()
933        }
934        #[cfg(not(target_arch = "wasm32"))]
935        {
936            let _ = key;
937            None
938        }
939    }
940
941    /// Set a value in Redis. Use `ttl_secs = 0` for no expiry.
942    ///
943    /// Returns `true` on success, `false` on failure.
944    ///
945    /// ```rust,ignore
946    /// redis::set("auth:perms:user-123", &perms_json, 3600); // 1 hour TTL
947    /// ```
948    pub fn set(key: &str, value: &str, ttl_secs: i32) -> bool {
949        #[cfg(target_arch = "wasm32")]
950        {
951            let key_bytes = key.as_bytes();
952            let val_bytes = value.as_bytes();
953            let result = unsafe {
954                redis_set(
955                    key_bytes.as_ptr() as i32,
956                    key_bytes.len() as i32,
957                    val_bytes.as_ptr() as i32,
958                    val_bytes.len() as i32,
959                    ttl_secs,
960                )
961            };
962            result == 0
963        }
964        #[cfg(not(target_arch = "wasm32"))]
965        {
966            let _ = (key, value, ttl_secs);
967            true
968        }
969    }
970
971    /// Delete a key from Redis.
972    ///
973    /// Returns `true` on success, `false` on failure.
974    ///
975    /// ```rust,ignore
976    /// redis::del("auth:perms:user-123");
977    /// ```
978    pub fn del(key: &str) -> bool {
979        #[cfg(target_arch = "wasm32")]
980        {
981            let bytes = key.as_bytes();
982            let result = unsafe { redis_del(bytes.as_ptr() as i32, bytes.len() as i32) };
983            result == 0
984        }
985        #[cfg(not(target_arch = "wasm32"))]
986        {
987            let _ = key;
988            true
989        }
990    }
991
992    /// Read the host response buffer after redis_get.
993    #[cfg(target_arch = "wasm32")]
994    fn read_redis_response() -> Option<String> {
995        let len = unsafe { get_host_response_len() };
996        if len <= 0 {
997            return None;
998        }
999        let mut buf = vec![0u8; len as usize];
1000        let read = unsafe { get_host_response(buf.as_mut_ptr() as i32, len) };
1001        if read <= 0 {
1002            return None;
1003        }
1004        buf.truncate(read as usize);
1005        String::from_utf8(buf).ok()
1006    }
1007}
1008
1009// ─── util module ────────────────────────────────────────────────────────
1010
1011/// Utility functions for common operations in WASM handlers.
1012pub mod util {
1013    #[allow(unused_imports)]
1014    use super::*;
1015
1016    /// Generate a new random UUID v4 string.
1017    ///
1018    /// ```rust,ignore
1019    /// let id = util::generate_uuid();
1020    /// log::info(&format!("New ID: {}", id));
1021    /// ```
1022    /// Get the current UTC time as an RFC3339 string.
1023    ///
1024    /// In WASM, this calls the platform host function. Outside WASM, it uses `SystemTime`.
1025    ///
1026    /// ```rust,ignore
1027    /// let now = util::current_time();
1028    /// log::info(&format!("Current time: {}", now));
1029    /// ```
1030    pub fn current_time() -> String {
1031        #[cfg(target_arch = "wasm32")]
1032        {
1033            let result = unsafe { super::current_time() };
1034            if result < 0 {
1035                return String::new();
1036            }
1037            let len = unsafe { get_host_response_len() };
1038            if len <= 0 {
1039                return String::new();
1040            }
1041            let mut buf = vec![0u8; len as usize];
1042            let read = unsafe { get_host_response(buf.as_mut_ptr() as i32, len) };
1043            if read <= 0 {
1044                return String::new();
1045            }
1046            buf.truncate(read as usize);
1047            String::from_utf8(buf).unwrap_or_default()
1048        }
1049
1050        #[cfg(not(target_arch = "wasm32"))]
1051        {
1052            let secs = std::time::SystemTime::now()
1053                .duration_since(std::time::UNIX_EPOCH)
1054                .map(|d| d.as_secs())
1055                .unwrap_or(0);
1056            format!("1970-01-01T00:00:00Z+{}", secs)
1057        }
1058    }
1059
1060    pub fn generate_uuid() -> String {
1061        #[cfg(target_arch = "wasm32")]
1062        {
1063            let result = unsafe { super::generate_uuid() };
1064            if result < 0 {
1065                return String::new();
1066            }
1067            let len = unsafe { get_host_response_len() };
1068            if len <= 0 {
1069                return String::new();
1070            }
1071            let mut buf = vec![0u8; len as usize];
1072            let read = unsafe { get_host_response(buf.as_mut_ptr() as i32, len) };
1073            if read <= 0 {
1074                return String::new();
1075            }
1076            buf.truncate(read as usize);
1077            String::from_utf8(buf).unwrap_or_default()
1078        }
1079
1080        #[cfg(not(target_arch = "wasm32"))]
1081        {
1082            format!(
1083                "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
1084                std::time::SystemTime::now()
1085                    .duration_since(std::time::UNIX_EPOCH)
1086                    .map(|d| d.as_nanos() as u32)
1087                    .unwrap_or(0),
1088                std::process::id() as u16,
1089                0u16,
1090                0x8000u16,
1091                0u64,
1092            )
1093        }
1094    }
1095}
1096
1097// ─── Handler runtime ─────────────────────────────────────────────────────────
1098
1099/// Internal function used by the `handler!` macro. Do not call directly.
1100#[doc(hidden)]
1101pub fn __run_handler<F>(ptr: i32, len: i32, f: F) -> i32
1102where
1103    F: FnOnce(Request) -> Response,
1104{
1105    // Read the request JSON from guest memory
1106    let request_json = unsafe {
1107        let slice = std::slice::from_raw_parts(ptr as *const u8, len as usize);
1108        String::from_utf8_lossy(slice).into_owned()
1109    };
1110
1111    // Parse the request
1112    let request = Request::from_json(&request_json).unwrap_or_else(|| Request {
1113        method: "GET".to_string(),
1114        handler: String::new(),
1115        headers: HashMap::new(),
1116        body: Value::Null,
1117        tenant: String::new(),
1118        service: String::new(),
1119        auth: None,
1120    });
1121
1122    // Call the user's handler
1123    let response = f(request);
1124    let response_bytes = response.into_data().into_bytes();
1125
1126    // Write response to guest memory: [4-byte LE length][data]
1127    let total = 4 + response_bytes.len();
1128    let layout = std::alloc::Layout::from_size_align(total, 1).expect("invalid layout");
1129    let out_ptr = unsafe { std::alloc::alloc(layout) };
1130
1131    unsafe {
1132        let len_bytes = (response_bytes.len() as u32).to_le_bytes();
1133        std::ptr::copy_nonoverlapping(len_bytes.as_ptr(), out_ptr, 4);
1134        std::ptr::copy_nonoverlapping(
1135            response_bytes.as_ptr(),
1136            out_ptr.add(4),
1137            response_bytes.len(),
1138        );
1139    }
1140
1141    out_ptr as i32
1142}
1143
1144// ─── Macros ──────────────────────────────────────────────────────────────────
1145
1146/// Initialize the cufflink-fn runtime. Call this once at the top of your `lib.rs`.
1147///
1148/// Exports the `alloc` function that the platform needs to pass data into
1149/// your WASM module.
1150///
1151/// ```rust,ignore
1152/// use cufflink_fn::prelude::*;
1153///
1154/// cufflink_fn::init!();
1155/// ```
1156#[macro_export]
1157macro_rules! init {
1158    () => {
1159        #[no_mangle]
1160        pub extern "C" fn alloc(size: i32) -> i32 {
1161            let layout = std::alloc::Layout::from_size_align(size as usize, 1).unwrap();
1162            unsafe { std::alloc::alloc(layout) as i32 }
1163        }
1164    };
1165}
1166
1167/// Define a handler function.
1168///
1169/// This macro generates the `#[no_mangle] extern "C"` boilerplate so your
1170/// handler is a plain Rust closure that receives a [`Request`] and returns
1171/// a [`Response`].
1172///
1173/// ```rust,ignore
1174/// use cufflink_fn::prelude::*;
1175///
1176/// cufflink_fn::init!();
1177///
1178/// handler!(get_stats, |req: Request| {
1179///     let rows = db::query("SELECT COUNT(*) as total FROM orders");
1180///     Response::json(&json!({"total": rows[0]["total"]}))
1181/// });
1182///
1183/// handler!(create_order, |req: Request| {
1184///     let body = req.body();
1185///     let customer = body["customer_id"].as_str().unwrap_or("unknown");
1186///     db::execute(&format!(
1187///         "INSERT INTO orders (customer_id, status) VALUES ('{}', 'pending')",
1188///         customer
1189///     ));
1190///     Response::json(&json!({"status": "created"}))
1191/// });
1192/// ```
1193#[macro_export]
1194macro_rules! handler {
1195    ($name:ident, |$req:ident : Request| $body:expr) => {
1196        #[no_mangle]
1197        pub extern "C" fn $name(ptr: i32, len: i32) -> i32 {
1198            $crate::__run_handler(ptr, len, |$req: $crate::Request| $body)
1199        }
1200    };
1201}
1202
1203// ─── Schema migration hook ───────────────────────────────────────────────────
1204
1205/// Helpers for the optional `on_migrate` schema migration hook.
1206///
1207/// Declared in your service:
1208///
1209/// ```rust,ignore
1210/// cufflink::service! {
1211///     name: "logistics-service",
1212///     mode: wasm,
1213///     tables: [PickupRequest],
1214///     on_migrate: "handle_on_migrate",
1215///     // ...
1216/// }
1217/// ```
1218///
1219/// And implemented as a handler that delegates to [`migrate::run`]:
1220///
1221/// ```rust,ignore
1222/// use cufflink_fn::prelude::*;
1223///
1224/// handler!(handle_on_migrate, |req: Request| {
1225///     migrate::run(req, |diff| {
1226///         if diff.added_column("pickup_requests", "approximate_item_count_min")
1227///             && diff.dropped_column("pickup_requests", "approximate_item_count")
1228///         {
1229///             db::execute("UPDATE pickup_requests \
1230///                          SET approximate_item_count_min = approximate_item_count \
1231///                          WHERE approximate_item_count_min IS NULL");
1232///         }
1233///         Ok(())
1234///     })
1235/// });
1236/// ```
1237///
1238/// The closure must be **idempotent** — cufflink may invoke it on retried
1239/// deploys, on no-op deploys, and on first-time deploys against a fresh
1240/// database. Use `WHERE … IS NULL` guards on every UPDATE.
1241pub mod migrate {
1242    use super::{Request, Response};
1243    pub use cufflink_types::SchemaDiff;
1244
1245    /// Parse the [`SchemaDiff`] from the request body and invoke `handler`.
1246    ///
1247    /// Returns a `200 {"ok": true}` response on success or a `400` with the
1248    /// error message if `handler` returns `Err`. If the request body fails
1249    /// to deserialise as a `SchemaDiff`, returns a `400` describing the
1250    /// parse error.
1251    pub fn run<F>(req: Request, handler: F) -> Response
1252    where
1253        F: FnOnce(SchemaDiff) -> Result<(), String>,
1254    {
1255        match serde_json::from_value::<SchemaDiff>(req.body().clone()) {
1256            Ok(diff) => match handler(diff) {
1257                Ok(()) => Response::json(&serde_json::json!({"ok": true})),
1258                Err(e) => Response::error(&e),
1259            },
1260            Err(e) => Response::error(&format!(
1261                "on_migrate: failed to parse SchemaDiff payload: {}",
1262                e
1263            )),
1264        }
1265    }
1266}
1267
1268// ─── Prelude ─────────────────────────────────────────────────────────────────
1269
1270/// Import everything you need to write handlers.
1271///
1272/// ```rust,ignore
1273/// use cufflink_fn::prelude::*;
1274/// ```
1275pub mod prelude {
1276    pub use crate::config;
1277    pub use crate::db;
1278    pub use crate::http;
1279    pub use crate::log;
1280    pub use crate::migrate;
1281    pub use crate::nats;
1282    pub use crate::redis;
1283    pub use crate::storage;
1284    pub use crate::util;
1285    pub use crate::Auth;
1286    pub use crate::Request;
1287    pub use crate::Response;
1288    pub use serde_json::{json, Value};
1289}
1290
1291// ─── Tests ───────────────────────────────────────────────────────────────────
1292
1293#[cfg(test)]
1294mod tests {
1295    use super::*;
1296    use serde_json::json;
1297
1298    #[test]
1299    fn test_request_parsing() {
1300        let json = serde_json::to_string(&json!({
1301            "method": "POST",
1302            "handler": "checkout",
1303            "headers": {"content-type": "application/json"},
1304            "body": {"item": "widget", "qty": 3},
1305            "tenant": "acme",
1306            "service": "shop"
1307        }))
1308        .unwrap();
1309
1310        let req = Request::from_json(&json).unwrap();
1311        assert_eq!(req.method(), "POST");
1312        assert_eq!(req.handler(), "checkout");
1313        assert_eq!(req.tenant(), "acme");
1314        assert_eq!(req.service(), "shop");
1315        assert_eq!(req.body()["item"], "widget");
1316        assert_eq!(req.body()["qty"], 3);
1317        assert_eq!(req.header("content-type"), Some("application/json"));
1318    }
1319
1320    #[test]
1321    fn test_request_missing_fields() {
1322        let json = r#"{"method": "GET"}"#;
1323        let req = Request::from_json(json).unwrap();
1324        assert_eq!(req.method(), "GET");
1325        assert_eq!(req.handler(), "");
1326        assert_eq!(req.tenant(), "");
1327        assert_eq!(req.body(), &Value::Null);
1328    }
1329
1330    #[test]
1331    fn test_response_json() {
1332        let resp = Response::json(&json!({"status": "ok", "count": 42}));
1333        let data = resp.into_data();
1334        let parsed: Value = serde_json::from_str(&data).unwrap();
1335        assert_eq!(parsed["status"], "ok");
1336        assert_eq!(parsed["count"], 42);
1337    }
1338
1339    #[test]
1340    fn test_response_error() {
1341        let resp = Response::error("something went wrong");
1342        let data = resp.into_data();
1343        let parsed: Value = serde_json::from_str(&data).unwrap();
1344        // error() returns status 400, so into_data wraps with __status/__body
1345        assert_eq!(parsed["__status"], 400);
1346        assert_eq!(parsed["__body"]["error"], "something went wrong");
1347    }
1348
1349    #[test]
1350    fn test_response_not_found() {
1351        let resp = Response::not_found("item not found");
1352        let data = resp.into_data();
1353        let parsed: Value = serde_json::from_str(&data).unwrap();
1354        assert_eq!(parsed["__status"], 404);
1355        assert_eq!(parsed["__body"]["error"], "item not found");
1356    }
1357
1358    #[test]
1359    fn test_response_with_status() {
1360        let resp = Response::json(&serde_json::json!({"ok": true})).with_status(201);
1361        let data = resp.into_data();
1362        let parsed: Value = serde_json::from_str(&data).unwrap();
1363        assert_eq!(parsed["__status"], 201);
1364        assert_eq!(parsed["__body"]["ok"], true);
1365    }
1366
1367    fn migrate_request(diff: serde_json::Value) -> Request {
1368        let payload = serde_json::to_string(&json!({
1369            "method": "POST",
1370            "handler": "handle_on_migrate",
1371            "headers": {},
1372            "body": diff,
1373            "tenant": "default",
1374            "service": "logistics-service",
1375        }))
1376        .unwrap();
1377        Request::from_json(&payload).unwrap()
1378    }
1379
1380    #[test]
1381    fn test_migrate_run_success() {
1382        let req = migrate_request(json!({
1383            "added_columns": [["pickups", "min"]],
1384            "dropped_columns": [["pickups", "midpoint"]],
1385        }));
1386        let resp = migrate::run(req, |diff| {
1387            assert!(diff.added_column("pickups", "min"));
1388            assert!(diff.dropped_column("pickups", "midpoint"));
1389            Ok(())
1390        });
1391        let parsed: Value = serde_json::from_str(&resp.into_data()).unwrap();
1392        assert_eq!(parsed["ok"], true);
1393    }
1394
1395    #[test]
1396    fn test_migrate_run_handler_error() {
1397        let req = migrate_request(json!({}));
1398        let resp = migrate::run(req, |_| Err("backfill failed".into()));
1399        let parsed: Value = serde_json::from_str(&resp.into_data()).unwrap();
1400        assert_eq!(parsed["__status"], 400);
1401        assert_eq!(parsed["__body"]["error"], "backfill failed");
1402    }
1403
1404    #[test]
1405    fn test_migrate_run_invalid_payload() {
1406        // body is a string, not a SchemaDiff object
1407        let req = migrate_request(json!("not a diff"));
1408        let resp = migrate::run(req, |_| {
1409            panic!("closure should not be called for invalid payload")
1410        });
1411        let parsed: Value = serde_json::from_str(&resp.into_data()).unwrap();
1412        assert_eq!(parsed["__status"], 400);
1413        assert!(parsed["__body"]["error"]
1414            .as_str()
1415            .unwrap()
1416            .contains("on_migrate: failed to parse SchemaDiff payload"));
1417    }
1418
1419    #[test]
1420    fn test_migrate_run_empty_diff() {
1421        let req = migrate_request(json!({}));
1422        let mut called = false;
1423        let resp = migrate::run(req, |diff| {
1424            assert!(diff.is_empty());
1425            called = true;
1426            Ok(())
1427        });
1428        assert!(called);
1429        let parsed: Value = serde_json::from_str(&resp.into_data()).unwrap();
1430        assert_eq!(parsed["ok"], true);
1431    }
1432
1433    #[test]
1434    fn test_response_200_no_wrapper() {
1435        let resp = Response::json(&serde_json::json!({"data": "test"}));
1436        let data = resp.into_data();
1437        let parsed: Value = serde_json::from_str(&data).unwrap();
1438        // 200 responses should NOT be wrapped
1439        assert_eq!(parsed["data"], "test");
1440        assert!(parsed.get("__status").is_none());
1441    }
1442
1443    #[test]
1444    fn test_response_empty() {
1445        let resp = Response::empty();
1446        let data = resp.into_data();
1447        let parsed: Value = serde_json::from_str(&data).unwrap();
1448        assert_eq!(parsed["ok"], true);
1449    }
1450
1451    #[test]
1452    fn test_response_text() {
1453        let resp = Response::text("hello world");
1454        let data = resp.into_data();
1455        let parsed: Value = serde_json::from_str(&data).unwrap();
1456        assert_eq!(parsed, "hello world");
1457    }
1458
1459    #[test]
1460    fn test_db_query_noop_on_native() {
1461        // On native (non-wasm) targets, db functions are no-ops
1462        let rows = db::query("SELECT 1");
1463        assert!(rows.is_empty());
1464    }
1465
1466    #[test]
1467    fn test_db_query_one_noop_on_native() {
1468        let row = db::query_one("SELECT 1");
1469        assert!(row.is_none());
1470    }
1471
1472    #[test]
1473    fn test_db_execute_noop_on_native() {
1474        let affected = db::execute("INSERT INTO x VALUES (1)");
1475        assert_eq!(affected, 0);
1476    }
1477
1478    #[test]
1479    fn test_nats_publish_noop_on_native() {
1480        let ok = nats::publish("test.subject", "payload");
1481        assert!(ok);
1482    }
1483
1484    #[test]
1485    fn test_request_with_auth() {
1486        let json = serde_json::to_string(&json!({
1487            "method": "POST",
1488            "handler": "checkout",
1489            "headers": {},
1490            "body": {},
1491            "tenant": "acme",
1492            "service": "shop",
1493            "auth": {
1494                "sub": "user-123",
1495                "preferred_username": "john",
1496                "name": "John Doe",
1497                "email": "john@example.com",
1498                "realm_roles": ["admin", "manager"],
1499                "claims": {"department": "engineering"}
1500            }
1501        }))
1502        .unwrap();
1503
1504        let req = Request::from_json(&json).unwrap();
1505        let auth = req.auth().unwrap();
1506        assert_eq!(auth.sub, "user-123");
1507        assert_eq!(auth.preferred_username.as_deref(), Some("john"));
1508        assert_eq!(auth.name.as_deref(), Some("John Doe"));
1509        assert_eq!(auth.email.as_deref(), Some("john@example.com"));
1510        assert!(auth.has_role("admin"));
1511        assert!(auth.has_role("manager"));
1512        assert!(!auth.has_role("viewer"));
1513        assert_eq!(
1514            auth.claim("department").and_then(|v| v.as_str()),
1515            Some("engineering")
1516        );
1517    }
1518
1519    #[test]
1520    fn test_request_without_auth() {
1521        let json = r#"{"method": "GET"}"#;
1522        let req = Request::from_json(json).unwrap();
1523        assert!(req.auth().is_none());
1524    }
1525
1526    #[test]
1527    fn test_request_null_auth() {
1528        let json = serde_json::to_string(&json!({
1529            "method": "GET",
1530            "auth": null
1531        }))
1532        .unwrap();
1533        let req = Request::from_json(&json).unwrap();
1534        assert!(req.auth().is_none());
1535    }
1536
1537    #[test]
1538    fn test_require_auth_success() {
1539        let json = serde_json::to_string(&json!({
1540            "method": "GET",
1541            "auth": {"sub": "user-1", "realm_roles": [], "claims": {}}
1542        }))
1543        .unwrap();
1544        let req = Request::from_json(&json).unwrap();
1545        assert!(req.require_auth().is_ok());
1546        assert_eq!(req.require_auth().unwrap().sub, "user-1");
1547    }
1548
1549    #[test]
1550    fn test_require_auth_fails_when_unauthenticated() {
1551        let json = r#"{"method": "GET"}"#;
1552        let req = Request::from_json(json).unwrap();
1553        assert!(req.require_auth().is_err());
1554    }
1555
1556    #[test]
1557    fn test_http_fetch_noop_on_native() {
1558        let resp = http::fetch("GET", "https://example.com", &[], None);
1559        assert!(resp.is_none());
1560    }
1561
1562    #[test]
1563    fn test_http_get_noop_on_native() {
1564        let resp = http::get("https://example.com", &[]);
1565        assert!(resp.is_none());
1566    }
1567
1568    #[test]
1569    fn test_http_post_noop_on_native() {
1570        let resp = http::post("https://example.com", &[], "{}");
1571        assert!(resp.is_none());
1572    }
1573
1574    #[test]
1575    fn test_storage_download_noop_on_native() {
1576        let data = storage::download("my-bucket", "images/photo.jpg");
1577        assert!(data.is_none());
1578    }
1579
1580    #[test]
1581    fn test_auth_permissions() {
1582        let json = serde_json::to_string(&json!({
1583            "method": "POST",
1584            "handler": "test",
1585            "headers": {},
1586            "body": {},
1587            "tenant": "acme",
1588            "service": "shop",
1589            "auth": {
1590                "sub": "user-1",
1591                "realm_roles": ["admin"],
1592                "claims": {},
1593                "permissions": ["staff:create", "staff:view", "items:*"],
1594                "role_names": ["admin", "manager"]
1595            }
1596        }))
1597        .unwrap();
1598
1599        let req = Request::from_json(&json).unwrap();
1600        let auth = req.auth().unwrap();
1601
1602        // Exact permission match
1603        assert!(auth.can("staff", "create"));
1604        assert!(auth.can("staff", "view"));
1605        assert!(!auth.can("staff", "delete"));
1606
1607        // Wildcard match
1608        assert!(auth.can("items", "create"));
1609        assert!(auth.can("items", "view"));
1610        assert!(auth.can("items", "delete"));
1611
1612        // No match
1613        assert!(!auth.can("batches", "view"));
1614
1615        // Cufflink roles
1616        assert!(auth.has_cufflink_role("admin"));
1617        assert!(auth.has_cufflink_role("manager"));
1618        assert!(!auth.has_cufflink_role("viewer"));
1619    }
1620
1621    #[test]
1622    fn test_auth_super_wildcard() {
1623        let auth = Auth {
1624            sub: "user-1".to_string(),
1625            preferred_username: None,
1626            name: None,
1627            email: None,
1628            realm_roles: vec![],
1629            claims: HashMap::new(),
1630            permissions: vec!["*".to_string()],
1631            role_names: vec!["superadmin".to_string()],
1632            is_service_account: false,
1633        };
1634
1635        assert!(auth.can("anything", "everything"));
1636        assert!(auth.can("staff", "create"));
1637    }
1638
1639    #[test]
1640    fn test_auth_empty_permissions() {
1641        let auth = Auth {
1642            sub: "user-1".to_string(),
1643            preferred_username: None,
1644            name: None,
1645            email: None,
1646            realm_roles: vec![],
1647            claims: HashMap::new(),
1648            permissions: vec![],
1649            role_names: vec![],
1650            is_service_account: false,
1651        };
1652
1653        assert!(!auth.can("staff", "create"));
1654        assert!(!auth.has_cufflink_role("admin"));
1655    }
1656
1657    #[test]
1658    fn test_redis_get_noop_on_native() {
1659        let val = redis::get("some-key");
1660        assert!(val.is_none());
1661    }
1662
1663    #[test]
1664    fn test_redis_set_noop_on_native() {
1665        let ok = redis::set("key", "value", 3600);
1666        assert!(ok);
1667    }
1668
1669    #[test]
1670    fn test_redis_del_noop_on_native() {
1671        let ok = redis::del("key");
1672        assert!(ok);
1673    }
1674
1675    #[test]
1676    fn test_http_fetch_response_helpers() {
1677        let resp = http::FetchResponse {
1678            status: 200,
1679            body: r#"{"key": "value"}"#.to_string(),
1680            body_encoding: "utf8".to_string(),
1681            headers: HashMap::new(),
1682        };
1683        assert!(resp.is_success());
1684        assert!(!resp.is_base64());
1685        let json = resp.json().unwrap();
1686        assert_eq!(json["key"], "value");
1687
1688        let err_resp = http::FetchResponse {
1689            status: 404,
1690            body: "not found".to_string(),
1691            body_encoding: "utf8".to_string(),
1692            headers: HashMap::new(),
1693        };
1694        assert!(!err_resp.is_success());
1695
1696        let binary_resp = http::FetchResponse {
1697            status: 200,
1698            body: "aW1hZ2VkYXRh".to_string(),
1699            body_encoding: "base64".to_string(),
1700            headers: HashMap::new(),
1701        };
1702        assert!(binary_resp.is_base64());
1703    }
1704}