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