Skip to main content

astrid_sdk/
lib.rs

1//! Safe Rust SDK for building User-Space Capsules on Astrid OS.
2//!
3//! # Design Intent
4//!
5//! This SDK is meant to feel like using `std`. Module names, function
6//! signatures, and type patterns follow Rust standard library conventions so
7//! that a Rust developer's instinct for "where would I find X?" gives the
8//! right answer without reading docs. When Astrid adds a concept that has no
9//! `std` counterpart (IPC, capabilities, interceptors), the API still follows
10//! the same style: typed handles, `Result`-based errors, and `impl AsRef`
11//! parameters.
12//!
13//! See `docs/sdk-ergonomics.md` for the full design rationale.
14//!
15//! # Module Layout (mirrors `std` where applicable)
16//!
17//! | Module          | std equivalent   | Purpose                                |
18//! |-----------------|------------------|----------------------------------------|
19//! | [`fs`]          | `std::fs`        | Virtual filesystem                     |
20//! | [`net`]         | `std::net`       | Unix domain sockets                    |
21//! | [`process`]     | `std::process`   | Host process execution                 |
22//! | [`env`]         | `std::env`       | Capsule configuration / env vars       |
23//! | [`time`]        | `std::time`      | Wall-clock access                      |
24//! | [`log`]         | `log` crate      | Structured logging                     |
25//! | [`runtime`]     | N/A              | OS signaling and caller context        |
26//! | [`ipc`]         | N/A              | Event bus messaging                    |
27//! | [`kv`]          | N/A              | Persistent key-value storage           |
28//! | [`http`]        | N/A              | Outbound HTTP requests                 |
29//! | [`cron`]        | N/A              | Scheduled background tasks             |
30//! | [`uplink`]      | N/A              | Direct frontend messaging              |
31//! | [`hooks`]       | N/A              | User middleware triggers               |
32//! | [`elicit`]      | N/A              | Interactive install/upgrade prompts    |
33//! | [`identity`]    | N/A              | Platform user identity resolution      |
34//! | [`approval`]    | N/A              | Human approval for sensitive actions   |
35
36#![allow(unsafe_code)]
37#![allow(missing_docs)]
38#![deny(clippy::all)]
39#![deny(unreachable_pub)]
40#![deny(clippy::unwrap_used)]
41#![cfg_attr(test, allow(clippy::unwrap_used))]
42
43use astrid_sys::*;
44use borsh::{BorshDeserialize, BorshSerialize};
45use serde::{Deserialize, Serialize, de::DeserializeOwned};
46use thiserror::Error;
47
48pub use borsh;
49pub use serde;
50pub use serde_json;
51
52// Re-exported for the #[capsule] macro's generated code. Not part of the
53// public API - capsule authors should never need to import these directly.
54#[doc(hidden)]
55pub use extism_pdk;
56#[doc(hidden)]
57pub use schemars;
58
59/// Core error type for SDK operations
60#[derive(Error, Debug)]
61pub enum SysError {
62    #[error("Host function call failed: {0}")]
63    HostError(#[from] extism_pdk::Error),
64    #[error("JSON serialization error: {0}")]
65    JsonError(#[from] serde_json::Error),
66    #[error("MessagePack serialization error: {0}")]
67    MsgPackEncodeError(#[from] rmp_serde::encode::Error),
68    #[error("MessagePack deserialization error: {0}")]
69    MsgPackDecodeError(#[from] rmp_serde::decode::Error),
70    #[error("Borsh serialization error: {0}")]
71    BorshError(#[from] std::io::Error),
72    #[error("API logic error: {0}")]
73    ApiError(String),
74}
75
76/// Virtual filesystem (mirrors `std::fs` naming).
77pub mod fs {
78    use super::*;
79
80    /// Check if a path exists. Like `std::fs::exists` (nightly).
81    pub fn exists(path: impl AsRef<[u8]>) -> Result<bool, SysError> {
82        let result = unsafe { astrid_fs_exists(path.as_ref().to_vec())? };
83        Ok(!result.is_empty() && result[0] != 0)
84    }
85
86    /// Read the entire contents of a file as bytes. Like `std::fs::read`.
87    pub fn read(path: impl AsRef<[u8]>) -> Result<Vec<u8>, SysError> {
88        let result = unsafe { astrid_read_file(path.as_ref().to_vec())? };
89        Ok(result)
90    }
91
92    /// Read the entire contents of a file as a string. Like `std::fs::read_to_string`.
93    pub fn read_to_string(path: impl AsRef<[u8]>) -> Result<String, SysError> {
94        let bytes = read(path)?;
95        String::from_utf8(bytes).map_err(|e| SysError::ApiError(e.to_string()))
96    }
97
98    /// Write bytes to a file. Like `std::fs::write`.
99    pub fn write(path: impl AsRef<[u8]>, contents: impl AsRef<[u8]>) -> Result<(), SysError> {
100        unsafe { astrid_write_file(path.as_ref().to_vec(), contents.as_ref().to_vec())? };
101        Ok(())
102    }
103
104    /// Create a directory. Like `std::fs::create_dir`.
105    pub fn create_dir(path: impl AsRef<[u8]>) -> Result<(), SysError> {
106        unsafe { astrid_fs_mkdir(path.as_ref().to_vec())? };
107        Ok(())
108    }
109
110    /// Read directory entries. Like `std::fs::read_dir`.
111    pub fn read_dir(path: impl AsRef<[u8]>) -> Result<Vec<u8>, SysError> {
112        let result = unsafe { astrid_fs_readdir(path.as_ref().to_vec())? };
113        Ok(result)
114    }
115
116    /// Get file metadata. Like `std::fs::metadata`.
117    pub fn metadata(path: impl AsRef<[u8]>) -> Result<Vec<u8>, SysError> {
118        let result = unsafe { astrid_fs_stat(path.as_ref().to_vec())? };
119        Ok(result)
120    }
121
122    /// Remove a file. Like `std::fs::remove_file`.
123    pub fn remove_file(path: impl AsRef<[u8]>) -> Result<(), SysError> {
124        unsafe { astrid_fs_unlink(path.as_ref().to_vec())? };
125        Ok(())
126    }
127}
128
129/// Event bus messaging (like `std::sync::mpsc` but topic-based).
130pub mod ipc {
131    use super::*;
132
133    /// An active subscription to an IPC topic. Returned by [`subscribe`].
134    ///
135    /// Follows the typed-handle pattern used by [`crate::net::ListenerHandle`].
136    #[derive(Debug, Clone)]
137    pub struct SubscriptionHandle(pub(crate) Vec<u8>);
138
139    impl SubscriptionHandle {
140        /// Raw handle bytes for interop with lower-level APIs.
141        #[must_use]
142        pub fn as_bytes(&self) -> &[u8] {
143            &self.0
144        }
145    }
146
147    // Allow existing code using `impl AsRef<[u8]>` to pass a SubscriptionHandle.
148    impl AsRef<[u8]> for SubscriptionHandle {
149        fn as_ref(&self) -> &[u8] {
150            &self.0
151        }
152    }
153
154    pub fn publish_bytes(topic: impl AsRef<[u8]>, payload: &[u8]) -> Result<(), SysError> {
155        unsafe { astrid_ipc_publish(topic.as_ref().to_vec(), payload.to_vec())? };
156        Ok(())
157    }
158
159    pub fn publish_json<T: Serialize>(
160        topic: impl AsRef<[u8]>,
161        payload: &T,
162    ) -> Result<(), SysError> {
163        let bytes = serde_json::to_vec(payload)?;
164        publish_bytes(topic, &bytes)
165    }
166
167    pub fn publish_msgpack<T: Serialize>(
168        topic: impl AsRef<[u8]>,
169        payload: &T,
170    ) -> Result<(), SysError> {
171        let bytes = rmp_serde::to_vec_named(payload)?;
172        publish_bytes(topic, &bytes)
173    }
174
175    /// Subscribe to an IPC topic. Returns a typed handle for polling/receiving.
176    pub fn subscribe(topic: impl AsRef<[u8]>) -> Result<SubscriptionHandle, SysError> {
177        let handle_bytes = unsafe { astrid_ipc_subscribe(topic.as_ref().to_vec())? };
178        Ok(SubscriptionHandle(handle_bytes))
179    }
180
181    pub fn unsubscribe(handle: &SubscriptionHandle) -> Result<(), SysError> {
182        unsafe { astrid_ipc_unsubscribe(handle.0.clone())? };
183        Ok(())
184    }
185
186    pub fn poll_bytes(handle: &SubscriptionHandle) -> Result<Vec<u8>, SysError> {
187        let message_bytes = unsafe { astrid_ipc_poll(handle.0.clone())? };
188        Ok(message_bytes)
189    }
190
191    /// Block until a message arrives on a subscription handle, or timeout.
192    ///
193    /// Returns the message envelope (same format as `poll_bytes`), or an
194    /// empty-messages envelope if the timeout expires with no messages.
195    /// Max timeout is capped at 60 000 ms by the host.
196    pub fn recv_bytes(handle: &SubscriptionHandle, timeout_ms: u64) -> Result<Vec<u8>, SysError> {
197        let timeout_str = timeout_ms.to_string();
198        let message_bytes = unsafe { astrid_ipc_recv(handle.0.clone(), timeout_str.into_bytes())? };
199        Ok(message_bytes)
200    }
201}
202
203/// Direct frontend messaging (uplinks to CLI, Telegram, etc.).
204pub mod uplink {
205    use super::*;
206
207    /// An opaque uplink connection identifier. Returned by [`register`].
208    #[derive(Debug, Clone)]
209    pub struct UplinkId(pub(crate) Vec<u8>);
210
211    impl UplinkId {
212        /// Raw ID bytes for interop with lower-level APIs.
213        #[must_use]
214        pub fn as_bytes(&self) -> &[u8] {
215            &self.0
216        }
217    }
218
219    impl AsRef<[u8]> for UplinkId {
220        fn as_ref(&self) -> &[u8] {
221            &self.0
222        }
223    }
224
225    /// Register a new uplink connection. Returns a typed [`UplinkId`].
226    pub fn register(
227        name: impl AsRef<[u8]>,
228        platform: impl AsRef<[u8]>,
229        profile: impl AsRef<[u8]>,
230    ) -> Result<UplinkId, SysError> {
231        let id_bytes = unsafe {
232            astrid_uplink_register(
233                name.as_ref().to_vec(),
234                platform.as_ref().to_vec(),
235                profile.as_ref().to_vec(),
236            )?
237        };
238        Ok(UplinkId(id_bytes))
239    }
240
241    /// Send bytes to a user via an uplink.
242    pub fn send_bytes(
243        uplink_id: &UplinkId,
244        platform_user_id: impl AsRef<[u8]>,
245        content: &[u8],
246    ) -> Result<Vec<u8>, SysError> {
247        let result = unsafe {
248            astrid_uplink_send(
249                uplink_id.0.clone(),
250                platform_user_id.as_ref().to_vec(),
251                content.to_vec(),
252            )?
253        };
254        Ok(result)
255    }
256}
257
258/// The KV Airlock — Persistent Key-Value Storage
259pub mod kv {
260    use super::*;
261
262    pub fn get_bytes(key: impl AsRef<[u8]>) -> Result<Vec<u8>, SysError> {
263        let result = unsafe { astrid_kv_get(key.as_ref().to_vec())? };
264        Ok(result)
265    }
266
267    pub fn set_bytes(key: impl AsRef<[u8]>, value: &[u8]) -> Result<(), SysError> {
268        unsafe { astrid_kv_set(key.as_ref().to_vec(), value.to_vec())? };
269        Ok(())
270    }
271
272    pub fn get_json<T: DeserializeOwned>(key: impl AsRef<[u8]>) -> Result<T, SysError> {
273        let bytes = get_bytes(key)?;
274        let parsed = serde_json::from_slice(&bytes)?;
275        Ok(parsed)
276    }
277
278    pub fn set_json<T: Serialize>(key: impl AsRef<[u8]>, value: &T) -> Result<(), SysError> {
279        let bytes = serde_json::to_vec(value)?;
280        set_bytes(key, &bytes)
281    }
282
283    /// Delete a key from the KV store.
284    ///
285    /// This is idempotent: deleting a non-existent key succeeds silently.
286    /// The underlying store returns whether the key existed, but that
287    /// information is not surfaced through the WASM host boundary.
288    pub fn delete(key: impl AsRef<[u8]>) -> Result<(), SysError> {
289        unsafe { astrid_kv_delete(key.as_ref().to_vec())? };
290        Ok(())
291    }
292
293    /// List all keys matching a prefix.
294    ///
295    /// Returns an empty vec if no keys match. The prefix is matched
296    /// against key names within the capsule's scoped namespace.
297    pub fn list_keys(prefix: impl AsRef<[u8]>) -> Result<Vec<String>, SysError> {
298        let result = unsafe { astrid_kv_list_keys(prefix.as_ref().to_vec())? };
299        let keys: Vec<String> = serde_json::from_slice(&result)?;
300        Ok(keys)
301    }
302
303    /// Delete all keys matching a prefix.
304    ///
305    /// Returns the number of keys deleted. The prefix is matched
306    /// against key names within the capsule's scoped namespace.
307    pub fn clear_prefix(prefix: impl AsRef<[u8]>) -> Result<u64, SysError> {
308        let result = unsafe { astrid_kv_clear_prefix(prefix.as_ref().to_vec())? };
309        let count: u64 = serde_json::from_slice(&result)?;
310        Ok(count)
311    }
312
313    pub fn get_borsh<T: BorshDeserialize>(key: impl AsRef<[u8]>) -> Result<T, SysError> {
314        let bytes = get_bytes(key)?;
315        let parsed = borsh::from_slice(&bytes)?;
316        Ok(parsed)
317    }
318
319    pub fn set_borsh<T: BorshSerialize>(key: impl AsRef<[u8]>, value: &T) -> Result<(), SysError> {
320        let bytes = borsh::to_vec(value)?;
321        set_bytes(key, &bytes)
322    }
323
324    // ---- Versioned KV helpers ----
325
326    /// Internal envelope for versioned KV data.
327    ///
328    /// Wire format: `{"__sv": <version>, "data": <payload>}`.
329    /// The `__sv` prefix is deliberately ugly to avoid collision with
330    /// user struct fields.
331    #[derive(Serialize, Deserialize)]
332    struct VersionedEnvelope<T> {
333        #[serde(rename = "__sv")]
334        schema_version: u32,
335        data: T,
336    }
337
338    /// Result of reading versioned data from KV.
339    #[derive(Debug)]
340    pub enum Versioned<T> {
341        /// Data is at the expected schema version.
342        Current(T),
343        /// Data is at an older version and needs migration.
344        NeedsMigration {
345            /// Raw JSON value of the `data` field.
346            raw: serde_json::Value,
347            /// The schema version that was stored.
348            stored_version: u32,
349        },
350        /// Key exists but data has no version envelope (pre-versioning legacy data).
351        Unversioned(serde_json::Value),
352        /// Key does not exist in KV.
353        NotFound,
354    }
355
356    /// Write versioned data to KV, wrapped in a schema-version envelope.
357    ///
358    /// The stored JSON looks like `{"__sv": 1, "data": { ... }}`.
359    /// Use [`get_versioned`] or [`get_versioned_or_migrate`] to read it back.
360    pub fn set_versioned<T: Serialize>(
361        key: impl AsRef<[u8]>,
362        value: &T,
363        version: u32,
364    ) -> Result<(), SysError> {
365        let envelope = VersionedEnvelope {
366            schema_version: version,
367            data: value,
368        };
369        set_json(key, &envelope)
370    }
371
372    /// Read versioned data from KV.
373    ///
374    /// Returns [`Versioned::Current`] if the stored version matches
375    /// `current_version`. Returns [`Versioned::NeedsMigration`] for older
376    /// versions. Returns an error for versions newer than `current_version`
377    /// (fail secure - don't silently interpret data from a schema you don't
378    /// understand).
379    ///
380    /// Data written by plain [`set_json`] (no envelope) returns
381    /// [`Versioned::Unversioned`].
382    pub fn get_versioned<T: DeserializeOwned>(
383        key: impl AsRef<[u8]>,
384        current_version: u32,
385    ) -> Result<Versioned<T>, SysError> {
386        let bytes = get_bytes(&key)?;
387        parse_versioned(&bytes, current_version)
388    }
389
390    /// Core parsing logic for versioned KV data, separated from FFI for
391    /// testability. Operates on raw bytes as returned by `get_bytes`.
392    fn parse_versioned<T: DeserializeOwned>(
393        bytes: &[u8],
394        current_version: u32,
395    ) -> Result<Versioned<T>, SysError> {
396        // The host function `astrid_kv_get` returns an empty slice when the
397        // key is absent. A present key written via set_json/set_versioned
398        // always has at least the JSON envelope bytes, so empty = not found.
399        if bytes.is_empty() {
400            return Ok(Versioned::NotFound);
401        }
402
403        let mut value: serde_json::Value = serde_json::from_slice(bytes)?;
404
405        // Detect envelope by checking for __sv (u64) + data fields.
406        // If __sv is present but malformed (not a number, or missing data),
407        // return an error rather than silently treating as unversioned.
408        let sv_field = value.get("__sv");
409        let has_sv = sv_field.is_some();
410        let envelope_version = sv_field.and_then(|v| v.as_u64());
411        let has_data = value.get("data").is_some();
412
413        match (has_sv, envelope_version, has_data) {
414            // Valid envelope: __sv is a u64 and data is present.
415            // Take ownership of the data field via remove() to avoid cloning.
416            (_, Some(v), true) => {
417                let v = u32::try_from(v)
418                    .map_err(|_| SysError::ApiError("schema version exceeds u32::MAX".into()))?;
419                // Safety: the match guard confirmed has_data=true, so
420                // value is an object with a "data" key. This is infallible.
421                let data = value
422                    .as_object_mut()
423                    .and_then(|m| m.remove("data"))
424                    .expect("data field guaranteed by match condition");
425                if v == current_version {
426                    let parsed: T = serde_json::from_value(data)?;
427                    Ok(Versioned::Current(parsed))
428                } else if v < current_version {
429                    Ok(Versioned::NeedsMigration {
430                        raw: data,
431                        stored_version: v,
432                    })
433                } else {
434                    Err(SysError::ApiError(format!(
435                        "stored schema version {v} is newer than current \
436                         version {current_version} - cannot safely read"
437                    )))
438                }
439            },
440            // Malformed envelope: __sv present but data missing or __sv not a number.
441            (true, _, _) => Err(SysError::ApiError(
442                "malformed versioned envelope: __sv field present but \
443                 data field missing or __sv is not a number"
444                    .into(),
445            )),
446            // No __sv field at all: plain unversioned data.
447            (false, _, _) => Ok(Versioned::Unversioned(value)),
448        }
449    }
450
451    /// Read versioned data, automatically migrating older versions.
452    ///
453    /// `migrate_fn` receives the raw JSON and the stored version, and must
454    /// return a `T` at `current_version`. The migrated value is automatically
455    /// saved back to KV.
456    ///
457    /// **Warning:** The original data is overwritten after a successful
458    /// migration. If the write-back fails, the original data is preserved
459    /// and the migration will be re-attempted on the next call. Ensure
460    /// `migrate_fn` is idempotent and correct - there is no rollback
461    /// after a successful write.
462    ///
463    /// For [`Versioned::Unversioned`] data, `migrate_fn` is called with
464    /// version 0. For [`Versioned::NotFound`], returns `None`.
465    pub fn get_versioned_or_migrate<T: Serialize + DeserializeOwned>(
466        key: impl AsRef<[u8]>,
467        current_version: u32,
468        migrate_fn: impl FnOnce(serde_json::Value, u32) -> Result<T, SysError>,
469    ) -> Result<Option<T>, SysError> {
470        let key = key.as_ref();
471
472        match get_versioned::<T>(key, current_version)? {
473            Versioned::Current(data) => Ok(Some(data)),
474            Versioned::NeedsMigration {
475                raw,
476                stored_version,
477            } => {
478                let migrated = migrate_fn(raw, stored_version)?;
479                set_versioned(key, &migrated, current_version)?;
480                Ok(Some(migrated))
481            },
482            Versioned::Unversioned(raw) => {
483                let migrated = migrate_fn(raw, 0)?;
484                set_versioned(key, &migrated, current_version)?;
485                Ok(Some(migrated))
486            },
487            Versioned::NotFound => Ok(None),
488        }
489    }
490
491    #[cfg(test)]
492    mod tests {
493        use super::*;
494
495        #[derive(Debug, Serialize, Deserialize, PartialEq)]
496        struct TestData {
497            name: String,
498            count: u32,
499        }
500
501        // ---- Envelope serialization tests ----
502
503        #[test]
504        fn versioned_envelope_roundtrip() {
505            let envelope = VersionedEnvelope {
506                schema_version: 1,
507                data: TestData {
508                    name: "hello".into(),
509                    count: 42,
510                },
511            };
512            let json = serde_json::to_string(&envelope).unwrap();
513            assert!(json.contains("\"__sv\":1"));
514            assert!(json.contains("\"data\":{"));
515
516            let parsed: VersionedEnvelope<TestData> = serde_json::from_str(&json).unwrap();
517            assert_eq!(parsed.schema_version, 1);
518            assert_eq!(
519                parsed.data,
520                TestData {
521                    name: "hello".into(),
522                    count: 42,
523                }
524            );
525        }
526
527        #[test]
528        fn versioned_envelope_wire_format() {
529            let envelope = VersionedEnvelope {
530                schema_version: 3,
531                data: serde_json::json!({"key": "value"}),
532            };
533            let json = serde_json::to_string(&envelope).unwrap();
534            let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
535
536            assert_eq!(parsed["__sv"], 3);
537            assert_eq!(parsed["data"]["key"], "value");
538        }
539
540        // ---- parse_versioned logic tests ----
541
542        #[test]
543        fn parse_versioned_empty_bytes_returns_not_found() {
544            let result = parse_versioned::<TestData>(b"", 1).unwrap();
545            assert!(matches!(result, Versioned::NotFound));
546        }
547
548        #[test]
549        fn parse_versioned_current_version_returns_current() {
550            let bytes = br#"{"__sv":2,"data":{"name":"hello","count":42}}"#;
551            let result = parse_versioned::<TestData>(bytes, 2).unwrap();
552            match result {
553                Versioned::Current(data) => {
554                    assert_eq!(data.name, "hello");
555                    assert_eq!(data.count, 42);
556                },
557                other => panic!("expected Current, got {other:?}"),
558            }
559        }
560
561        #[test]
562        fn parse_versioned_older_version_returns_needs_migration() {
563            let bytes = br#"{"__sv":1,"data":{"name":"old","count":1}}"#;
564            let result = parse_versioned::<TestData>(bytes, 3).unwrap();
565            match result {
566                Versioned::NeedsMigration {
567                    raw,
568                    stored_version,
569                } => {
570                    assert_eq!(stored_version, 1);
571                    assert_eq!(raw["name"], "old");
572                    assert_eq!(raw["count"], 1);
573                },
574                other => panic!("expected NeedsMigration, got {other:?}"),
575            }
576        }
577
578        #[test]
579        fn parse_versioned_newer_version_returns_error() {
580            let bytes = br#"{"__sv":5,"data":{"name":"future","count":0}}"#;
581            let result = parse_versioned::<TestData>(bytes, 2);
582            assert!(result.is_err());
583            let err = result.unwrap_err().to_string();
584            assert!(
585                err.contains("newer than current"),
586                "error should mention newer version: {err}"
587            );
588        }
589
590        #[test]
591        fn parse_versioned_plain_json_returns_unversioned() {
592            let bytes = br#"{"name":"legacy","count":99}"#;
593            let result = parse_versioned::<TestData>(bytes, 1).unwrap();
594            match result {
595                Versioned::Unversioned(val) => {
596                    assert_eq!(val["name"], "legacy");
597                    assert_eq!(val["count"], 99);
598                },
599                other => panic!("expected Unversioned, got {other:?}"),
600            }
601        }
602
603        #[test]
604        fn parse_versioned_malformed_sv_without_data_returns_error() {
605            let bytes = br#"{"__sv":1,"payload":"something"}"#;
606            let result = parse_versioned::<TestData>(bytes, 1);
607            assert!(result.is_err());
608            let err = result.unwrap_err().to_string();
609            assert!(
610                err.contains("malformed"),
611                "error should mention malformed envelope: {err}"
612            );
613        }
614
615        #[test]
616        fn parse_versioned_non_numeric_sv_returns_error() {
617            let bytes = br#"{"__sv":"one","data":{}}"#;
618            let result = parse_versioned::<TestData>(bytes, 1);
619            assert!(result.is_err());
620            let err = result.unwrap_err().to_string();
621            assert!(
622                err.contains("malformed"),
623                "error should mention malformed envelope: {err}"
624            );
625        }
626
627        #[test]
628        fn parse_versioned_version_zero_is_valid() {
629            // Version 0 is a legitimate version (initial schema).
630            let bytes = br#"{"__sv":0,"data":{"name":"v0","count":0}}"#;
631            let result = parse_versioned::<TestData>(bytes, 0).unwrap();
632            assert!(matches!(result, Versioned::Current(_)));
633        }
634
635        #[test]
636        fn parse_versioned_invalid_json_returns_error() {
637            let result = parse_versioned::<TestData>(b"not json", 1);
638            assert!(result.is_err());
639        }
640    }
641}
642
643/// The HTTP Airlock — External Network Requests
644pub mod http {
645    use super::*;
646
647    /// Issue a raw HTTP request. The `request_bytes` payload format depends on the Kernel's expectation
648    /// (e.g. JSON or MsgPack representation of the HTTP request).
649    pub fn request_bytes(request_bytes: &[u8]) -> Result<Vec<u8>, SysError> {
650        let result = unsafe { astrid_http_request(request_bytes.to_vec())? };
651        Ok(result)
652    }
653}
654
655/// The Cron Airlock — Dynamic Background Scheduling
656pub mod cron {
657    use super::*;
658
659    /// Schedule a dynamic cron job that will wake up this capsule.
660    pub fn schedule(
661        name: impl AsRef<[u8]>,
662        schedule: impl AsRef<[u8]>,
663        payload: &[u8],
664    ) -> Result<(), SysError> {
665        unsafe {
666            astrid_cron_schedule(
667                name.as_ref().to_vec(),
668                schedule.as_ref().to_vec(),
669                payload.to_vec(),
670            )?
671        };
672        Ok(())
673    }
674
675    /// Cancel a previously scheduled dynamic cron job.
676    pub fn cancel(name: impl AsRef<[u8]>) -> Result<(), SysError> {
677        unsafe { astrid_cron_cancel(name.as_ref().to_vec())? };
678        Ok(())
679    }
680}
681
682pub mod types;
683
684/// Capsule configuration (like `std::env`).
685///
686/// In the Astrid model, capsule config entries are the equivalent of
687/// environment variables. The kernel injects them at load time.
688pub mod env {
689    use super::*;
690
691    /// Well-known config key for the kernel's Unix domain socket path.
692    pub const CONFIG_SOCKET_PATH: &str = "ASTRID_SOCKET_PATH";
693
694    /// Read a config value as raw bytes. Like `std::env::var_os`.
695    pub fn var_bytes(key: impl AsRef<[u8]>) -> Result<Vec<u8>, SysError> {
696        let result = unsafe { astrid_get_config(key.as_ref().to_vec())? };
697        Ok(result)
698    }
699
700    /// Read a config value as a UTF-8 string. Like `std::env::var`.
701    pub fn var(key: impl AsRef<[u8]>) -> Result<String, SysError> {
702        let bytes = var_bytes(key)?;
703        String::from_utf8(bytes).map_err(|e| SysError::ApiError(e.to_string()))
704    }
705}
706
707/// Wall-clock access (like `std::time`).
708pub mod time {
709    use super::*;
710
711    /// Returns the current wall-clock time as milliseconds since the UNIX epoch.
712    ///
713    /// This is a host call - the WASM guest has no direct access to system time.
714    /// Returns 0 if the host clock is unavailable.
715    pub fn now_ms() -> Result<u64, SysError> {
716        let bytes = unsafe { astrid_clock_ms()? };
717        let s = String::from_utf8_lossy(&bytes);
718        s.trim()
719            .parse::<u64>()
720            .map_err(|e| SysError::ApiError(format!("clock_ms parse error: {e}")))
721    }
722}
723
724/// Structured logging.
725pub mod log {
726    use super::*;
727
728    /// Log a message at the given level.
729    pub fn log(level: impl AsRef<[u8]>, message: impl AsRef<[u8]>) -> Result<(), SysError> {
730        unsafe { astrid_log(level.as_ref().to_vec(), message.as_ref().to_vec())? };
731        Ok(())
732    }
733
734    /// Log at DEBUG level.
735    pub fn debug(message: impl AsRef<[u8]>) -> Result<(), SysError> {
736        log("debug", message)
737    }
738
739    /// Log at INFO level.
740    pub fn info(message: impl AsRef<[u8]>) -> Result<(), SysError> {
741        log("info", message)
742    }
743
744    /// Log at WARN level.
745    pub fn warn(message: impl AsRef<[u8]>) -> Result<(), SysError> {
746        log("warn", message)
747    }
748
749    /// Log at ERROR level.
750    pub fn error(message: impl AsRef<[u8]>) -> Result<(), SysError> {
751        log("error", message)
752    }
753}
754
755/// OS runtime introspection and signaling.
756pub mod runtime {
757    use super::*;
758
759    /// Signal that the capsule's run loop is ready.
760    ///
761    /// Call this after setting up IPC subscriptions in `run()` to let the
762    /// kernel know this capsule is ready to receive events. The kernel waits
763    /// for this signal before loading dependent capsules.
764    pub fn signal_ready() -> Result<(), SysError> {
765        unsafe { astrid_signal_ready()? };
766        Ok(())
767    }
768
769    /// Retrieves the caller context (User ID and Session ID) for the current execution.
770    pub fn caller() -> Result<crate::types::CallerContext, SysError> {
771        let bytes = unsafe { astrid_get_caller()? };
772        serde_json::from_slice(&bytes)
773            .map_err(|e| SysError::ApiError(format!("failed to parse caller context: {e}")))
774    }
775
776    /// Returns the kernel's Unix domain socket path.
777    ///
778    /// Reads from the well-known `ASTRID_SOCKET_PATH` config key that the
779    /// kernel injects into every capsule at load time.
780    pub fn socket_path() -> Result<String, SysError> {
781        let raw = crate::env::var(crate::env::CONFIG_SOCKET_PATH)?;
782        // var() returns JSON-encoded values (quoted strings).
783        // Use proper JSON parsing to handle escape sequences correctly.
784        let path = serde_json::from_str::<String>(raw.trim()).or_else(|_| {
785            // Fallback: if the value isn't valid JSON, use it raw.
786            if raw.is_empty() {
787                Err(SysError::ApiError(
788                    "ASTRID_SOCKET_PATH config key is empty".to_string(),
789                ))
790            } else {
791                Ok(raw)
792            }
793        })?;
794        // Reject paths with null bytes - they would silently truncate at the OS level.
795        if path.contains('\0') {
796            return Err(SysError::ApiError(
797                "ASTRID_SOCKET_PATH contains null byte".to_string(),
798            ));
799        }
800        Ok(path)
801    }
802}
803
804/// The Hooks Airlock — Executing User Middleware
805pub mod hooks {
806    use super::*;
807
808    pub fn trigger(event_bytes: &[u8]) -> Result<Vec<u8>, SysError> {
809        unsafe { Ok(astrid_trigger_hook(event_bytes.to_vec())?) }
810    }
811}
812
813/// Cross-capsule capability queries.
814///
815/// Allows a capsule to check whether another capsule (identified by its
816/// IPC session UUID) has a specific manifest capability. Used by the
817/// prompt builder to enforce `allow_prompt_injection` gating.
818pub mod capabilities {
819    use super::*;
820
821    /// Check whether a capsule has a specific capability.
822    ///
823    /// Returns `true` if the capsule identified by `source_uuid` has the
824    /// given `capability` declared in its manifest. Returns `false` for
825    /// unknown UUIDs, unknown capabilities, or on any error (fail-closed).
826    pub fn check(source_uuid: &str, capability: &str) -> Result<bool, SysError> {
827        let request = serde_json::json!({
828            "source_uuid": source_uuid,
829            "capability": capability,
830        });
831        let request_bytes = serde_json::to_vec(&request)?;
832        let response_bytes = unsafe { astrid_check_capsule_capability(request_bytes)? };
833        let response: serde_json::Value = serde_json::from_slice(&response_bytes)?;
834        Ok(response["allowed"].as_bool().unwrap_or(false))
835    }
836}
837
838pub mod net;
839pub mod process {
840    use super::*;
841    use serde::{Deserialize, Serialize};
842
843    /// Request payload for spawning a host process.
844    #[derive(Debug, Serialize)]
845    pub struct ProcessRequest<'a> {
846        pub cmd: &'a str,
847        pub args: &'a [&'a str],
848    }
849
850    /// Result returned from a spawned host process.
851    #[derive(Debug, Deserialize)]
852    pub struct ProcessResult {
853        pub stdout: String,
854        pub stderr: String,
855        pub exit_code: i32,
856    }
857
858    /// Spawns a native host process (blocks until completion).
859    /// The Capsule must have the `host_process` capability granted for this command.
860    pub fn spawn(cmd: &str, args: &[&str]) -> Result<ProcessResult, SysError> {
861        let req = ProcessRequest { cmd, args };
862        let req_bytes = serde_json::to_vec(&req)?;
863        let result_bytes = unsafe { astrid_spawn_host(req_bytes)? };
864        let result: ProcessResult = serde_json::from_slice(&result_bytes)?;
865        Ok(result)
866    }
867
868    // -------------------------------------------------------------------
869    // Background process management
870    // -------------------------------------------------------------------
871
872    /// Handle returned when a background process is spawned.
873    #[derive(Debug, Deserialize)]
874    pub struct BackgroundProcessHandle {
875        /// Opaque handle ID (not an OS PID).
876        pub id: u64,
877    }
878
879    /// Buffered logs and status from a background process.
880    #[derive(Debug, Deserialize)]
881    pub struct ProcessLogs {
882        /// New stdout output since the last read.
883        pub stdout: String,
884        /// New stderr output since the last read.
885        pub stderr: String,
886        /// Whether the process is still running.
887        pub running: bool,
888        /// Exit code if the process has exited.
889        pub exit_code: Option<i32>,
890    }
891
892    /// Result from killing a background process.
893    #[derive(Debug, Deserialize)]
894    pub struct KillResult {
895        /// Whether the process was successfully killed.
896        pub killed: bool,
897        /// Exit code of the terminated process.
898        pub exit_code: Option<i32>,
899        /// Any remaining buffered stdout.
900        pub stdout: String,
901        /// Any remaining buffered stderr.
902        pub stderr: String,
903    }
904
905    /// Spawn a background host process.
906    ///
907    /// Returns an opaque handle that can be used with [`read_logs`] and
908    /// [`kill`]. The process runs sandboxed with piped stdout/stderr.
909    pub fn spawn_background(cmd: &str, args: &[&str]) -> Result<BackgroundProcessHandle, SysError> {
910        let req = ProcessRequest { cmd, args };
911        let req_bytes = serde_json::to_vec(&req)?;
912        let result_bytes = unsafe { astrid_spawn_background_host(req_bytes)? };
913        let result: BackgroundProcessHandle = serde_json::from_slice(&result_bytes)?;
914        Ok(result)
915    }
916
917    /// Read buffered output from a background process.
918    ///
919    /// Each call drains the buffer and returns only NEW output since the
920    /// last read. Also reports whether the process is still running.
921    pub fn read_logs(id: u64) -> Result<ProcessLogs, SysError> {
922        #[derive(Serialize)]
923        struct Req {
924            id: u64,
925        }
926        let req_bytes = serde_json::to_vec(&Req { id })?;
927        let result_bytes = unsafe { astrid_read_process_logs_host(req_bytes)? };
928        let result: ProcessLogs = serde_json::from_slice(&result_bytes)?;
929        Ok(result)
930    }
931
932    /// Kill a background process and release its resources.
933    ///
934    /// Returns any remaining buffered output along with the exit code.
935    pub fn kill(id: u64) -> Result<KillResult, SysError> {
936        #[derive(Serialize)]
937        struct Req {
938            id: u64,
939        }
940        let req_bytes = serde_json::to_vec(&Req { id })?;
941        let result_bytes = unsafe { astrid_kill_process_host(req_bytes)? };
942        let result: KillResult = serde_json::from_slice(&result_bytes)?;
943        Ok(result)
944    }
945}
946
947/// The Elicit Airlock - User Input During Install/Upgrade Lifecycle
948///
949/// These functions are only callable during `#[astrid::install]` and
950/// `#[astrid::upgrade]` hooks. Calling them from a tool or interceptor
951/// returns a host error.
952pub mod elicit {
953    use super::*;
954
955    /// Internal request structure sent to the `astrid_elicit` host function.
956    #[derive(Serialize)]
957    struct ElicitRequest<'a> {
958        #[serde(rename = "type")]
959        kind: &'a str,
960        key: &'a str,
961        #[serde(skip_serializing_if = "Option::is_none")]
962        description: Option<&'a str>,
963        #[serde(skip_serializing_if = "Option::is_none")]
964        options: Option<&'a [&'a str]>,
965        #[serde(skip_serializing_if = "Option::is_none")]
966        default: Option<&'a str>,
967    }
968
969    /// Validates that the elicit key is non-empty and not whitespace-only.
970    fn validate_key(key: &str) -> Result<(), SysError> {
971        if key.trim().is_empty() {
972            return Err(SysError::ApiError("elicit key must not be empty".into()));
973        }
974        Ok(())
975    }
976
977    /// Store a secret via the kernel's `SecretStore`. The capsule **never**
978    /// receives the value. Returns `Ok(())` confirming the user provided it.
979    pub fn secret(key: &str, description: &str) -> Result<(), SysError> {
980        validate_key(key)?;
981        let req = ElicitRequest {
982            kind: "secret",
983            key,
984            description: Some(description),
985            options: None,
986            default: None,
987        };
988        let req_bytes = serde_json::to_vec(&req)?;
989        // SAFETY: FFI call to Extism host function. The host validates the
990        // request and returns a well-formed JSON response or an Extism error.
991        let resp_bytes = unsafe { astrid_elicit(req_bytes)? };
992
993        #[derive(serde::Deserialize)]
994        struct SecretResp {
995            ok: bool,
996        }
997        let resp: SecretResp = serde_json::from_slice(&resp_bytes)?;
998        if !resp.ok {
999            return Err(SysError::ApiError(
1000                "kernel did not confirm secret storage".into(),
1001            ));
1002        }
1003        Ok(())
1004    }
1005
1006    /// Check if a secret has been configured (without reading it).
1007    pub fn has_secret(key: &str) -> Result<bool, SysError> {
1008        validate_key(key)?;
1009        #[derive(Serialize)]
1010        struct HasSecretRequest<'a> {
1011            key: &'a str,
1012        }
1013        let req_bytes = serde_json::to_vec(&HasSecretRequest { key })?;
1014        // SAFETY: FFI call to Extism host function. The host checks the
1015        // SecretStore and returns a JSON response or an Extism error.
1016        let resp_bytes = unsafe { astrid_has_secret(req_bytes)? };
1017
1018        #[derive(serde::Deserialize)]
1019        struct ExistsResp {
1020            exists: bool,
1021        }
1022        let resp: ExistsResp = serde_json::from_slice(&resp_bytes)?;
1023        Ok(resp.exists)
1024    }
1025
1026    /// Shared implementation for text elicitation with optional default.
1027    fn elicit_text(
1028        key: &str,
1029        description: &str,
1030        default: Option<&str>,
1031    ) -> Result<String, SysError> {
1032        validate_key(key)?;
1033        let req = ElicitRequest {
1034            kind: "text",
1035            key,
1036            description: Some(description),
1037            options: None,
1038            default,
1039        };
1040        let req_bytes = serde_json::to_vec(&req)?;
1041        // SAFETY: FFI call to Extism host function. The host validates the
1042        // request and returns a well-formed JSON response or an Extism error.
1043        let resp_bytes = unsafe { astrid_elicit(req_bytes)? };
1044
1045        #[derive(serde::Deserialize)]
1046        struct TextResp {
1047            value: String,
1048        }
1049        let resp: TextResp = serde_json::from_slice(&resp_bytes)?;
1050        Ok(resp.value)
1051    }
1052
1053    /// Prompt for a text value. Blocks until the user responds.
1054    /// Use [`secret()`] for sensitive data - this returns the value to the capsule.
1055    pub fn text(key: &str, description: &str) -> Result<String, SysError> {
1056        elicit_text(key, description, None)
1057    }
1058
1059    /// Prompt with a default value pre-filled.
1060    pub fn text_with_default(
1061        key: &str,
1062        description: &str,
1063        default: &str,
1064    ) -> Result<String, SysError> {
1065        elicit_text(key, description, Some(default))
1066    }
1067
1068    /// Prompt for a selection from a list. Returns the selected value.
1069    pub fn select(key: &str, description: &str, options: &[&str]) -> Result<String, SysError> {
1070        validate_key(key)?;
1071        if options.is_empty() {
1072            return Err(SysError::ApiError(
1073                "select requires at least one option".into(),
1074            ));
1075        }
1076        let req = ElicitRequest {
1077            kind: "select",
1078            key,
1079            description: Some(description),
1080            options: Some(options),
1081            default: None,
1082        };
1083        let req_bytes = serde_json::to_vec(&req)?;
1084        // SAFETY: FFI call to Extism host function. The host validates the
1085        // request and returns a well-formed JSON response or an Extism error.
1086        let resp_bytes = unsafe { astrid_elicit(req_bytes)? };
1087
1088        #[derive(serde::Deserialize)]
1089        struct SelectResp {
1090            value: String,
1091        }
1092        let resp: SelectResp = serde_json::from_slice(&resp_bytes)?;
1093        if !options.iter().any(|o| *o == resp.value) {
1094            let truncated: String = resp.value.chars().take(64).collect();
1095            return Err(SysError::ApiError(format!(
1096                "host returned value '{truncated}' not in provided options",
1097            )));
1098        }
1099        Ok(resp.value)
1100    }
1101
1102    /// Prompt for multiple text values (array input).
1103    pub fn array(key: &str, description: &str) -> Result<Vec<String>, SysError> {
1104        validate_key(key)?;
1105        let req = ElicitRequest {
1106            kind: "array",
1107            key,
1108            description: Some(description),
1109            options: None,
1110            default: None,
1111        };
1112        let req_bytes = serde_json::to_vec(&req)?;
1113        // SAFETY: FFI call to Extism host function. The host validates the
1114        // request and returns a well-formed JSON response or an Extism error.
1115        let resp_bytes = unsafe { astrid_elicit(req_bytes)? };
1116
1117        #[derive(serde::Deserialize)]
1118        struct ArrayResp {
1119            values: Vec<String>,
1120        }
1121        let resp: ArrayResp = serde_json::from_slice(&resp_bytes)?;
1122        Ok(resp.values)
1123    }
1124}
1125
1126/// Auto-subscribed interceptor bindings for run-loop capsules.
1127///
1128/// When a capsule declares both `run()` and `[[interceptor]]`, the runtime
1129/// auto-subscribes to each interceptor's topic and delivers events through
1130/// the IPC channel the run loop already reads from. This module provides
1131/// helpers to query the subscription mappings and dispatch events by action.
1132pub mod interceptors {
1133    use super::*;
1134
1135    /// A single interceptor subscription binding.
1136    #[derive(Debug, serde::Deserialize)]
1137    pub struct InterceptorBinding {
1138        /// The IPC subscription handle ID (as bytes for use with `ipc::poll_bytes`/`ipc::recv_bytes`).
1139        pub handle_id: u64,
1140        /// The interceptor action name from the manifest.
1141        pub action: String,
1142        /// The event topic this interceptor subscribes to.
1143        pub topic: String,
1144    }
1145
1146    impl InterceptorBinding {
1147        /// Return a subscription handle for use with `ipc::poll_bytes` / `ipc::recv_bytes`.
1148        #[must_use]
1149        pub fn subscription_handle(&self) -> ipc::SubscriptionHandle {
1150            ipc::SubscriptionHandle(self.handle_id.to_string().into_bytes())
1151        }
1152
1153        /// Return the raw handle ID bytes (for lower-level interop).
1154        #[must_use]
1155        pub fn handle_bytes(&self) -> Vec<u8> {
1156            self.handle_id.to_string().into_bytes()
1157        }
1158    }
1159
1160    /// Query the runtime for auto-subscribed interceptor handles.
1161    ///
1162    /// Returns an empty vec if this capsule has no auto-subscribed interceptors
1163    /// (i.e. it does not have both `run()` and `[[interceptor]]`).
1164    pub fn bindings() -> Result<Vec<InterceptorBinding>, SysError> {
1165        // SAFETY: FFI call to Extism host function. The host serializes
1166        // `HostState.interceptor_handles` to JSON and returns valid UTF-8 bytes.
1167        // Errors are propagated via the `?` operator.
1168        let bytes = unsafe { astrid_get_interceptor_handles()? };
1169        let bindings: Vec<InterceptorBinding> = serde_json::from_slice(&bytes)?;
1170        Ok(bindings)
1171    }
1172
1173    /// Poll all interceptor subscriptions and dispatch pending events.
1174    ///
1175    /// For each binding with pending messages, calls
1176    /// `handler(action, envelope_bytes)` once with the full batch envelope
1177    /// (JSON with `messages` array, `dropped`, and `lagged` fields).
1178    /// Bindings with no pending messages are skipped.
1179    pub fn poll(
1180        bindings: &[InterceptorBinding],
1181        mut handler: impl FnMut(&str, &[u8]),
1182    ) -> Result<(), SysError> {
1183        #[derive(serde::Deserialize)]
1184        struct PollEnvelope {
1185            messages: Vec<serde_json::Value>,
1186        }
1187
1188        for binding in bindings {
1189            let handle = binding.subscription_handle();
1190            let envelope = ipc::poll_bytes(&handle)?;
1191
1192            // poll_bytes always returns a JSON envelope like
1193            // `{"messages":[],"dropped":0,"lagged":0}`. Check the
1194            // messages array before calling the handler.
1195            let parsed: PollEnvelope = serde_json::from_slice(&envelope)?;
1196            if !parsed.messages.is_empty() {
1197                handler(&binding.action, &envelope);
1198            }
1199        }
1200        Ok(())
1201    }
1202}
1203
1204/// Request human approval for sensitive actions from within a capsule.
1205///
1206/// Any capsule can call [`approval::request`] to block until the frontend
1207/// user approves or denies an action. The host function checks the
1208/// `AllowanceStore` for a matching pattern first (instant path), and only
1209/// prompts the user when no allowance exists.
1210///
1211/// # Example
1212///
1213/// ```ignore
1214/// use astrid_sdk::prelude::*;
1215///
1216/// let result = approval::request("git push", "git push origin main", "high")?;
1217/// if !result.approved {
1218///     return Err(SysError::ApiError("Action denied by user".into()));
1219/// }
1220/// ```
1221/// Platform identity resolution and linking.
1222///
1223/// Capsules use this module to resolve platform-specific user identities
1224/// (e.g. Discord user IDs, Twitch usernames) to Astrid-native user IDs,
1225/// and to manage the links between them.
1226///
1227/// Requires the `identity` capability in `Capsule.toml`:
1228/// - `["resolve"]` - resolve platform users
1229/// - `["link"]` - resolve, link, unlink, and list links
1230/// - `["admin"]` - all of the above plus create new users
1231pub mod identity {
1232    use super::*;
1233
1234    /// A resolved Astrid user returned by [`resolve`].
1235    #[derive(Debug)]
1236    pub struct ResolvedUser {
1237        /// The Astrid-native user ID (UUID).
1238        pub user_id: String,
1239        /// Optional display name.
1240        pub display_name: Option<String>,
1241    }
1242
1243    /// A platform-to-Astrid identity link.
1244    #[derive(Debug)]
1245    pub struct Link {
1246        /// Platform name (e.g. "discord", "twitch").
1247        pub platform: String,
1248        /// Platform-specific user identifier.
1249        pub platform_user_id: String,
1250        /// The Astrid user this is linked to.
1251        pub astrid_user_id: String,
1252        /// When the link was created (RFC 3339).
1253        pub linked_at: String,
1254        /// How the link was established (e.g. "system", "chat_command").
1255        pub method: String,
1256    }
1257
1258    /// Resolve a platform user to an Astrid user.
1259    ///
1260    /// Returns `Ok(Some(user))` if the platform identity is linked,
1261    /// `Ok(None)` if not found. Requires `identity = ["resolve"]` or higher.
1262    pub fn resolve(
1263        platform: &str,
1264        platform_user_id: &str,
1265    ) -> Result<Option<ResolvedUser>, SysError> {
1266        #[derive(Serialize)]
1267        struct Req<'a> {
1268            platform: &'a str,
1269            platform_user_id: &'a str,
1270        }
1271
1272        let req_bytes = serde_json::to_vec(&Req {
1273            platform,
1274            platform_user_id,
1275        })?;
1276
1277        // SAFETY: FFI call to Extism host function.
1278        let resp_bytes = unsafe { astrid_identity_resolve(req_bytes)? };
1279
1280        #[derive(Deserialize)]
1281        struct Resp {
1282            found: bool,
1283            user_id: Option<String>,
1284            display_name: Option<String>,
1285            error: Option<String>,
1286        }
1287        let resp: Resp = serde_json::from_slice(&resp_bytes)?;
1288        if resp.found {
1289            let user_id = resp.user_id.ok_or_else(|| {
1290                SysError::ApiError("host returned found=true but user_id was missing".into())
1291            })?;
1292            Ok(Some(ResolvedUser {
1293                user_id,
1294                display_name: resp.display_name,
1295            }))
1296        } else if let Some(err) = resp.error {
1297            Err(SysError::ApiError(err))
1298        } else {
1299            Ok(None)
1300        }
1301    }
1302
1303    /// Link a platform identity to an Astrid user.
1304    ///
1305    /// - `method` describes how the link was established (e.g. "chat_command", "system").
1306    ///
1307    /// Returns the created link on success. Requires `identity = ["link"]` or higher.
1308    pub fn link(
1309        platform: &str,
1310        platform_user_id: &str,
1311        astrid_user_id: &str,
1312        method: &str,
1313    ) -> Result<Link, SysError> {
1314        #[derive(Serialize)]
1315        struct Req<'a> {
1316            platform: &'a str,
1317            platform_user_id: &'a str,
1318            astrid_user_id: &'a str,
1319            method: &'a str,
1320        }
1321
1322        let req_bytes = serde_json::to_vec(&Req {
1323            platform,
1324            platform_user_id,
1325            astrid_user_id,
1326            method,
1327        })?;
1328
1329        // SAFETY: FFI call to Extism host function.
1330        let resp_bytes = unsafe { astrid_identity_link(req_bytes)? };
1331
1332        #[derive(Deserialize)]
1333        struct LinkInfo {
1334            platform: String,
1335            platform_user_id: String,
1336            astrid_user_id: String,
1337            linked_at: String,
1338            method: String,
1339        }
1340        #[derive(Deserialize)]
1341        struct Resp {
1342            ok: bool,
1343            error: Option<String>,
1344            link: Option<LinkInfo>,
1345        }
1346        let resp: Resp = serde_json::from_slice(&resp_bytes)?;
1347        if !resp.ok {
1348            return Err(SysError::ApiError(
1349                resp.error.unwrap_or_else(|| "identity link failed".into()),
1350            ));
1351        }
1352        let l = resp
1353            .link
1354            .ok_or_else(|| SysError::ApiError("missing link in response".into()))?;
1355        Ok(Link {
1356            platform: l.platform,
1357            platform_user_id: l.platform_user_id,
1358            astrid_user_id: l.astrid_user_id,
1359            linked_at: l.linked_at,
1360            method: l.method,
1361        })
1362    }
1363
1364    /// Unlink a platform identity from its Astrid user.
1365    ///
1366    /// Returns `true` if a link was removed, `false` if none existed.
1367    /// Requires `identity = ["link"]` or higher.
1368    pub fn unlink(platform: &str, platform_user_id: &str) -> Result<bool, SysError> {
1369        #[derive(Serialize)]
1370        struct Req<'a> {
1371            platform: &'a str,
1372            platform_user_id: &'a str,
1373        }
1374
1375        let req_bytes = serde_json::to_vec(&Req {
1376            platform,
1377            platform_user_id,
1378        })?;
1379
1380        // SAFETY: FFI call to Extism host function.
1381        let resp_bytes = unsafe { astrid_identity_unlink(req_bytes)? };
1382
1383        #[derive(Deserialize)]
1384        struct Resp {
1385            ok: bool,
1386            error: Option<String>,
1387            removed: Option<bool>,
1388        }
1389        let resp: Resp = serde_json::from_slice(&resp_bytes)?;
1390        if !resp.ok {
1391            return Err(SysError::ApiError(
1392                resp.error
1393                    .unwrap_or_else(|| "identity unlink failed".into()),
1394            ));
1395        }
1396        Ok(resp.removed.unwrap_or(false))
1397    }
1398
1399    /// Create a new Astrid user.
1400    ///
1401    /// Returns the UUID of the newly created user.
1402    /// Requires `identity = ["admin"]`.
1403    pub fn create_user(display_name: Option<&str>) -> Result<String, SysError> {
1404        #[derive(Serialize)]
1405        struct Req<'a> {
1406            display_name: Option<&'a str>,
1407        }
1408
1409        let req_bytes = serde_json::to_vec(&Req { display_name })?;
1410
1411        // SAFETY: FFI call to Extism host function.
1412        let resp_bytes = unsafe { astrid_identity_create_user(req_bytes)? };
1413
1414        #[derive(Deserialize)]
1415        struct Resp {
1416            ok: bool,
1417            error: Option<String>,
1418            user_id: Option<String>,
1419        }
1420        let resp: Resp = serde_json::from_slice(&resp_bytes)?;
1421        if !resp.ok {
1422            return Err(SysError::ApiError(
1423                resp.error
1424                    .unwrap_or_else(|| "identity create_user failed".into()),
1425            ));
1426        }
1427        resp.user_id
1428            .ok_or_else(|| SysError::ApiError("missing user_id in response".into()))
1429    }
1430
1431    /// List all platform links for an Astrid user.
1432    ///
1433    /// Returns all linked platform identities for the given user UUID.
1434    /// Requires `identity = ["link"]` or higher.
1435    pub fn list_links(astrid_user_id: &str) -> Result<Vec<Link>, SysError> {
1436        #[derive(Serialize)]
1437        struct Req<'a> {
1438            astrid_user_id: &'a str,
1439        }
1440
1441        let req_bytes = serde_json::to_vec(&Req { astrid_user_id })?;
1442
1443        // SAFETY: FFI call to Extism host function.
1444        let resp_bytes = unsafe { astrid_identity_list_links(req_bytes)? };
1445
1446        #[derive(Deserialize)]
1447        struct LinkInfo {
1448            platform: String,
1449            platform_user_id: String,
1450            astrid_user_id: String,
1451            linked_at: String,
1452            method: String,
1453        }
1454        #[derive(Deserialize)]
1455        struct Resp {
1456            ok: bool,
1457            error: Option<String>,
1458            links: Option<Vec<LinkInfo>>,
1459        }
1460        let resp: Resp = serde_json::from_slice(&resp_bytes)?;
1461        if !resp.ok {
1462            return Err(SysError::ApiError(
1463                resp.error
1464                    .unwrap_or_else(|| "identity list_links failed".into()),
1465            ));
1466        }
1467        Ok(resp
1468            .links
1469            .unwrap_or_default()
1470            .into_iter()
1471            .map(|l| Link {
1472                platform: l.platform,
1473                platform_user_id: l.platform_user_id,
1474                astrid_user_id: l.astrid_user_id,
1475                linked_at: l.linked_at,
1476                method: l.method,
1477            })
1478            .collect())
1479    }
1480}
1481
1482pub mod approval {
1483    use super::*;
1484
1485    /// The result of an approval request.
1486    #[derive(Debug)]
1487    pub struct ApprovalResult {
1488        /// Whether the action was approved.
1489        pub approved: bool,
1490        /// The decision string: "approve", "approve_session",
1491        /// "approve_always", "deny", or "allowance" (auto-approved).
1492        pub decision: String,
1493    }
1494
1495    /// Request human approval for a sensitive action.
1496    ///
1497    /// Blocks the capsule until the frontend user responds or the request
1498    /// times out. If an existing allowance matches, returns immediately
1499    /// without prompting.
1500    ///
1501    /// - `action` - short description of the action (e.g. "git push")
1502    /// - `resource` - full resource identifier (e.g. "git push origin main")
1503    /// - `risk_level` - one of "low", "medium", "high", "critical"
1504    pub fn request(
1505        action: &str,
1506        resource: &str,
1507        risk_level: &str,
1508    ) -> Result<ApprovalResult, SysError> {
1509        #[derive(Serialize)]
1510        struct ApprovalRequest<'a> {
1511            action: &'a str,
1512            resource: &'a str,
1513            risk_level: &'a str,
1514        }
1515
1516        let req = ApprovalRequest {
1517            action,
1518            resource,
1519            risk_level,
1520        };
1521        let req_bytes = serde_json::to_vec(&req)?;
1522
1523        // SAFETY: FFI call to Extism host function. The host checks the
1524        // AllowanceStore, publishes ApprovalRequired if needed, blocks
1525        // until a response arrives, and returns a JSON result.
1526        let resp_bytes = unsafe { astrid_request_approval(req_bytes)? };
1527
1528        #[derive(Deserialize)]
1529        struct ApprovalResp {
1530            approved: bool,
1531            decision: String,
1532        }
1533        let resp: ApprovalResp = serde_json::from_slice(&resp_bytes)?;
1534        Ok(ApprovalResult {
1535            approved: resp.approved,
1536            decision: resp.decision,
1537        })
1538    }
1539}
1540
1541pub mod prelude {
1542    pub use crate::{
1543        SysError,
1544        // Astrid-specific modules
1545        approval,
1546        capabilities,
1547        cron,
1548        elicit,
1549        // std-mirrored modules
1550        env,
1551        fs,
1552        hooks,
1553        http,
1554        identity,
1555        interceptors,
1556        ipc,
1557        kv,
1558        log,
1559        net,
1560        process,
1561        runtime,
1562        time,
1563        uplink,
1564    };
1565
1566    #[cfg(feature = "derive")]
1567    pub use astrid_sdk_macros::capsule;
1568}