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