Skip to main content

pulse_client/
resources.rs

1//! Resource accessors — one per OpenAPI tag.
2
3use reqwest::Method;
4use serde_json::{json, Value};
5
6use crate::client::PulseClient;
7use crate::error::PulseError;
8
9// ---------------------------------------------------------------------------
10// AuthResource — client.auth()
11// ---------------------------------------------------------------------------
12
13pub struct AuthResource<'c> {
14    pub(crate) client: &'c PulseClient,
15}
16
17impl AuthResource<'_> {
18    /// `POST /api/auth/login` — exchanges username + password for a JWT.
19    ///
20    /// On success, the returned token is cached on the parent client so
21    /// subsequent calls authenticate automatically.
22    pub async fn login(&self, username: &str, password: &str) -> Result<Value, PulseError> {
23        let body = json!({ "username": username, "password": password });
24        let response = self
25            .client
26            .request(Method::POST, "/api/auth/login", Some(&body), false)
27            .await?;
28        cache_token(self.client, &response);
29        Ok(response)
30    }
31
32    /// `POST /api/auth/refresh` — exchanges a refresh token for a fresh JWT.
33    pub async fn refresh(&self, refresh_token: &str) -> Result<Value, PulseError> {
34        let body = json!({ "refreshToken": refresh_token });
35        let response = self
36            .client
37            .request(Method::POST, "/api/auth/refresh", Some(&body), false)
38            .await?;
39        cache_token(self.client, &response);
40        Ok(response)
41    }
42
43    /// `GET /api/auth/organizations` — orgs the current user is a member of.
44    pub async fn organizations(&self) -> Result<Vec<Value>, PulseError> {
45        let result = self
46            .client
47            .request(Method::GET, "/api/auth/organizations", None::<&()>, true)
48            .await?;
49        Ok(unwrap_list(&result, "organizations"))
50    }
51
52    /// `POST /api/auth/switch-org` — switches the active organisation.
53    /// The new JWT (with updated orgId claim) is cached on the parent client.
54    pub async fn switch_org(&self, org_id: &str) -> Result<Value, PulseError> {
55        let body = json!({ "orgId": org_id });
56        let response = self
57            .client
58            .request(Method::POST, "/api/auth/switch-org", Some(&body), true)
59            .await?;
60        cache_token(self.client, &response);
61        Ok(response)
62    }
63}
64
65fn cache_token(client: &PulseClient, response: &Value) {
66    if let Some(token) = response.get("token").and_then(Value::as_str) {
67        if !token.is_empty() {
68            client.set_token(token);
69        }
70    }
71}
72
73// ---------------------------------------------------------------------------
74// PipelinesResource — client.pipelines()
75// ---------------------------------------------------------------------------
76
77pub struct PipelinesResource<'c> {
78    pub(crate) client: &'c PulseClient,
79}
80
81impl PipelinesResource<'_> {
82    /// `GET /api/pulse/pipelines` — every pipeline in the current org.
83    pub async fn list(&self) -> Result<Vec<Value>, PulseError> {
84        let result = self
85            .client
86            .request(Method::GET, "/api/pulse/pipelines", None::<&()>, true)
87            .await?;
88        Ok(unwrap_list(&result, "pipelines"))
89    }
90
91    /// `GET /api/pulse/pipelines/{id}` — one pipeline by id.
92    pub async fn get(&self, pipeline_id: &str) -> Result<Value, PulseError> {
93        let path = format!("/api/pulse/pipelines/{}", encode_path(pipeline_id));
94        self.client
95            .request(Method::GET, &path, None::<&()>, true)
96            .await
97    }
98
99    /// `POST /api/pulse/pipelines` — creates + deploys a new pipeline.
100    ///
101    /// The definition must follow the `CreatePipelineRequest` schema (see
102    /// openapi.yaml). At minimum: `name` + `nodes`.
103    pub async fn create(&self, definition: &Value) -> Result<Value, PulseError> {
104        self.client
105            .request(Method::POST, "/api/pulse/pipelines", Some(definition), true)
106            .await
107    }
108
109    /// `DELETE /api/pulse/pipelines/{id}` — tears down the pipeline.
110    pub async fn delete(&self, pipeline_id: &str) -> Result<(), PulseError> {
111        let path = format!("/api/pulse/pipelines/{}", encode_path(pipeline_id));
112        self.client
113            .request(Method::DELETE, &path, None::<&()>, true)
114            .await?;
115        Ok(())
116    }
117}
118
119// ---------------------------------------------------------------------------
120// AgentsResource — client.agents()
121// ---------------------------------------------------------------------------
122
123pub struct AgentsResource<'c> {
124    pub(crate) client: &'c PulseClient,
125}
126
127impl AgentsResource<'_> {
128    /// `GET /api/pulse/agents` — every deployed agent in the current org.
129    pub async fn list(&self) -> Result<Vec<Value>, PulseError> {
130        let result = self
131            .client
132            .request(Method::GET, "/api/pulse/agents", None::<&()>, true)
133            .await?;
134        Ok(unwrap_list(&result, "agents"))
135    }
136
137    /// `GET /api/pulse/agents/{id}` — one agent by id.
138    pub async fn get(&self, agent_id: &str) -> Result<Value, PulseError> {
139        let path = format!("/api/pulse/agents/{}", encode_path(agent_id));
140        self.client
141            .request(Method::GET, &path, None::<&()>, true)
142            .await
143    }
144
145    /// B-115 Phase 1 — `PUT /api/pulse/agents/{id}`: replace the agent's config.
146    ///
147    /// `config` is the FULL agent config (not a partial merge) — at minimum
148    /// `name`. Optional fields (`engineType`, `inputTopic`, `outputTopic`,
149    /// `description`, `instances`, `monthlyBudget`, `config`) fall back to safe
150    /// defaults when omitted. See the `UpdateAgentRequest` schema in
151    /// `openapi.yaml`.
152    ///
153    /// Today this triggers a full stop + persist + start cycle on the engine
154    /// side — the agent is briefly unavailable while the swap happens.
155    /// Existing state in the agent's keyed store is preserved. Phase 2
156    /// (B-115-engine) will add atomic event-boundary swap so hot-reloadable
157    /// changes apply with no downtime.
158    ///
159    /// Returns the post-update agent snapshot (same shape as [`get`](Self::get)).
160    ///
161    /// # Errors
162    ///
163    /// - [`PulseError::Validation`] on a bad config (self-loop, invalid
164    ///   streaming operators)
165    /// - [`PulseError::NotFound`] if the agent doesn't exist
166    pub async fn update(&self, agent_id: &str, config: &Value) -> Result<Value, PulseError> {
167        let path = format!("/api/pulse/agents/{}", encode_path(agent_id));
168        self.client
169            .request(Method::PUT, &path, Some(config), true)
170            .await
171    }
172
173    /// `DELETE /api/pulse/agents/{id}` — stop the agent + remove its config row.
174    ///
175    /// The agent's keyed state store is also dropped. Requires the
176    /// `AGENT_DELETE` permission.
177    pub async fn delete(&self, agent_id: &str) -> Result<(), PulseError> {
178        let path = format!("/api/pulse/agents/{}", encode_path(agent_id));
179        self.client
180            .request::<()>(Method::DELETE, &path, None, true)
181            .await?;
182        Ok(())
183    }
184}
185
186// ---------------------------------------------------------------------------
187// TemplatesResource — client.templates()
188// ---------------------------------------------------------------------------
189
190pub struct TemplatesResource<'c> {
191    pub(crate) client: &'c PulseClient,
192}
193
194impl TemplatesResource<'_> {
195    /// `GET /api/pulse/templates` — the 223+ first-party templates.
196    pub async fn list(&self) -> Result<Vec<Value>, PulseError> {
197        let result = self
198            .client
199            .request(Method::GET, "/api/pulse/templates", None::<&()>, true)
200            .await?;
201        Ok(unwrap_list(&result, "templates"))
202    }
203}
204
205// ---------------------------------------------------------------------------
206// ModelsResource — client.models()
207// ---------------------------------------------------------------------------
208
209/// `client.models()` — B-112 embedded ML model registry.
210///
211/// Upload ONNX models that the streaming `ml_predict` operator scores events
212/// against, in-process on the Pulse engine (no model-server hop). Models are
213/// org-scoped; upload / delete require the ADMIN role.
214///
215/// # Example
216///
217/// ```no_run
218/// use pulse_client::{PulseClient, ModelUpload};
219/// use std::collections::BTreeMap;
220///
221/// # async fn run(client: &PulseClient) -> Result<(), pulse_client::PulseError> {
222/// let mut input = BTreeMap::new();
223/// input.insert("amount".to_string(), "float".to_string());
224/// input.insert("country".to_string(), "string".to_string());
225///
226/// client
227///     .models()
228///     .upload(
229///         ModelUpload::from_path("fraud-classifier", "./model.onnx")
230///             .input_schema(input),
231///     )
232///     .await?;
233/// # Ok(())
234/// # }
235/// ```
236pub struct ModelsResource<'c> {
237    pub(crate) client: &'c PulseClient,
238}
239
240/// B-112 — describes a model upload to [`ModelsResource::upload`].
241///
242/// Supply the model bytes either by file `path` (read at upload time) or as
243/// raw `data`. Exactly one of the two must be set — [`ModelsResource::upload`]
244/// returns a [`PulseError::InvalidConfig`] otherwise.
245#[derive(Debug, Clone, Default)]
246pub struct ModelUpload {
247    /// Model name referenced by `ml_predict(model = ...)`.
248    pub name: String,
249    /// Filesystem path to the `.onnx` file. Mutually exclusive with `data`.
250    pub path: Option<String>,
251    /// Raw model bytes. Mutually exclusive with `path`.
252    pub data: Option<Vec<u8>>,
253    /// Model runtime — only `"onnx"` is supported today. Defaults to `"onnx"`.
254    pub runtime: Option<String>,
255    /// Ordered feature-name → type map, used to pack features into the input
256    /// tensor (in the model's input order).
257    pub input_schema: Option<std::collections::BTreeMap<String, String>>,
258    /// Output-name → type map (informational).
259    pub output_schema: Option<std::collections::BTreeMap<String, String>>,
260}
261
262impl ModelUpload {
263    /// Upload from a filesystem path to the `.onnx` file.
264    pub fn from_path(name: impl Into<String>, path: impl Into<String>) -> Self {
265        Self {
266            name: name.into(),
267            path: Some(path.into()),
268            ..Self::default()
269        }
270    }
271
272    /// Upload from raw model bytes.
273    pub fn from_bytes(name: impl Into<String>, data: Vec<u8>) -> Self {
274        Self {
275            name: name.into(),
276            data: Some(data),
277            ..Self::default()
278        }
279    }
280
281    /// Override the runtime (default `"onnx"`).
282    pub fn runtime(mut self, runtime: impl Into<String>) -> Self {
283        self.runtime = Some(runtime.into());
284        self
285    }
286
287    /// Set the ordered input feature schema.
288    pub fn input_schema(mut self, schema: std::collections::BTreeMap<String, String>) -> Self {
289        self.input_schema = Some(schema);
290        self
291    }
292
293    /// Set the (informational) output schema.
294    pub fn output_schema(mut self, schema: std::collections::BTreeMap<String, String>) -> Self {
295        self.output_schema = Some(schema);
296        self
297    }
298}
299
300impl ModelsResource<'_> {
301    /// `POST /api/pulse/ml-models` — upload (or replace) a model.
302    ///
303    /// Sent as `multipart/form-data`: a file part named `model` carrying the
304    /// bytes, plus text parts `name`, `runtime`, and (when set) `inputSchema` /
305    /// `outputSchema` as JSON strings. Replacing an existing name hot-swaps the
306    /// model with no agent restart.
307    ///
308    /// Returns the persisted model metadata (name, runtime, sha256, version, …).
309    ///
310    /// # Errors
311    ///
312    /// - [`PulseError::InvalidConfig`] if `name` is blank, if neither or both
313    ///   of `path`/`data` are set, or if the model bytes are empty.
314    /// - [`PulseError::Transport`] if reading the file at `path` fails.
315    pub async fn upload(&self, upload: ModelUpload) -> Result<Value, PulseError> {
316        if upload.name.trim().is_empty() {
317            return Err(PulseError::InvalidConfig(
318                "model name must be a non-empty string".to_string(),
319            ));
320        }
321        if upload.path.is_some() == upload.data.is_some() {
322            return Err(PulseError::InvalidConfig(
323                "provide exactly one of 'path' or 'data'".to_string(),
324            ));
325        }
326
327        let (blob, filename) = match (&upload.path, upload.data) {
328            (Some(path), None) => {
329                let bytes = std::fs::read(path)
330                    .map_err(|e| PulseError::InvalidConfig(format!("read {path}: {e}")))?;
331                let filename = path
332                    .rsplit(['/', '\\'])
333                    .next()
334                    .filter(|s| !s.is_empty())
335                    .unwrap_or("model.onnx")
336                    .to_string();
337                (bytes, filename)
338            }
339            (None, Some(data)) => (data, format!("{}.onnx", upload.name)),
340            // Unreachable — guarded by the XOR check above.
341            _ => unreachable!("exactly one of path/data enforced above"),
342        };
343        if blob.is_empty() {
344            return Err(PulseError::InvalidConfig(
345                "model bytes are empty".to_string(),
346            ));
347        }
348
349        let runtime = upload.runtime.unwrap_or_else(|| "onnx".to_string());
350        let model_part = reqwest::multipart::Part::bytes(blob)
351            .file_name(filename)
352            .mime_str("application/octet-stream")
353            .map_err(PulseError::Transport)?;
354        let mut form = reqwest::multipart::Form::new()
355            .text("name", upload.name)
356            .text("runtime", runtime)
357            .part("model", model_part);
358        if let Some(schema) = upload.input_schema {
359            form = form.text("inputSchema", serde_json::to_string(&schema)?);
360        }
361        if let Some(schema) = upload.output_schema {
362            form = form.text("outputSchema", serde_json::to_string(&schema)?);
363        }
364
365        self.client
366            .request_multipart("/api/pulse/ml-models", form)
367            .await
368    }
369
370    /// `GET /api/pulse/ml-models` — models registered for the caller's org.
371    pub async fn list(&self) -> Result<Vec<Value>, PulseError> {
372        let result = self
373            .client
374            .request(Method::GET, "/api/pulse/ml-models", None::<&()>, true)
375            .await?;
376        Ok(unwrap_list(&result, "models"))
377    }
378
379    /// `GET /api/pulse/ml-models/{name}` — metadata for one model.
380    pub async fn get(&self, name: &str) -> Result<Value, PulseError> {
381        let path = format!("/api/pulse/ml-models/{}", encode_path(name));
382        self.client
383            .request(Method::GET, &path, None::<&()>, true)
384            .await
385    }
386
387    /// `DELETE /api/pulse/ml-models/{name}` — remove a model (ADMIN).
388    pub async fn delete(&self, name: &str) -> Result<(), PulseError> {
389        let path = format!("/api/pulse/ml-models/{}", encode_path(name));
390        self.client
391            .request::<()>(Method::DELETE, &path, None, true)
392            .await?;
393        Ok(())
394    }
395}
396
397// ---------------------------------------------------------------------------
398// WasmResource — client.wasm()
399// ---------------------------------------------------------------------------
400
401/// `client.wasm()` — B-110 sandboxed WASM module registry.
402///
403/// Upload WebAssembly modules that the streaming `wasm` operator runs over
404/// events, sandboxed in pure-Java Chicory on the engine (no host syscalls,
405/// bounded linear memory). Modules are org-scoped; upload / delete require the
406/// ADMIN role.
407///
408/// # Example
409///
410/// ```no_run
411/// use pulse_client::{PulseClient, WasmUpload};
412///
413/// # async fn run(client: &PulseClient) -> Result<(), pulse_client::PulseError> {
414/// client
415///     .wasm()
416///     .upload(WasmUpload::from_path("pii-redactor", "./redactor.wasm"))
417///     .await?;
418/// # Ok(())
419/// # }
420/// ```
421pub struct WasmResource<'c> {
422    pub(crate) client: &'c PulseClient,
423}
424
425/// B-110 — describes a WASM module upload to [`WasmResource::upload`].
426///
427/// Supply the module bytes either by file `path` (read at upload time) or as
428/// raw `data`. Exactly one of the two must be set — [`WasmResource::upload`]
429/// returns a [`PulseError::InvalidConfig`] otherwise.
430#[derive(Debug, Clone, Default)]
431pub struct WasmUpload {
432    /// Module name referenced by `wasm(module = ...)`.
433    pub name: String,
434    /// Filesystem path to the `.wasm` file. Mutually exclusive with `data`.
435    pub path: Option<String>,
436    /// Raw module bytes. Mutually exclusive with `path`.
437    pub data: Option<Vec<u8>>,
438    /// Optional human-readable description stored alongside the module.
439    pub description: Option<String>,
440}
441
442impl WasmUpload {
443    /// Upload from a filesystem path to the `.wasm` file.
444    pub fn from_path(name: impl Into<String>, path: impl Into<String>) -> Self {
445        Self {
446            name: name.into(),
447            path: Some(path.into()),
448            ..Self::default()
449        }
450    }
451
452    /// Upload from raw module bytes.
453    pub fn from_bytes(name: impl Into<String>, data: Vec<u8>) -> Self {
454        Self {
455            name: name.into(),
456            data: Some(data),
457            ..Self::default()
458        }
459    }
460
461    /// Attach a human-readable description.
462    pub fn description(mut self, description: impl Into<String>) -> Self {
463        self.description = Some(description.into());
464        self
465    }
466}
467
468impl WasmResource<'_> {
469    /// `POST /api/pulse/wasm-modules` — upload (or replace) a module.
470    ///
471    /// Sent as `multipart/form-data`: a file part named `module` carrying the
472    /// bytes, plus a text part `name` and (when set) `description`. The module
473    /// is validated server-side (must parse, import no host functions, export
474    /// alloc/process/memory) before persisting. Replacing an existing name
475    /// hot-swaps the module with no agent restart.
476    ///
477    /// Returns the persisted module metadata (name, sha256, version, …).
478    ///
479    /// # Errors
480    ///
481    /// - [`PulseError::InvalidConfig`] if `name` is blank, if neither or both
482    ///   of `path`/`data` are set, or if the module bytes are empty.
483    /// - [`PulseError::Transport`] if reading the file at `path` fails.
484    pub async fn upload(&self, upload: WasmUpload) -> Result<Value, PulseError> {
485        if upload.name.trim().is_empty() {
486            return Err(PulseError::InvalidConfig(
487                "module name must be a non-empty string".to_string(),
488            ));
489        }
490        if upload.path.is_some() == upload.data.is_some() {
491            return Err(PulseError::InvalidConfig(
492                "provide exactly one of 'path' or 'data'".to_string(),
493            ));
494        }
495
496        let (blob, filename) = match (&upload.path, upload.data) {
497            (Some(path), None) => {
498                let bytes = std::fs::read(path)
499                    .map_err(|e| PulseError::InvalidConfig(format!("read {path}: {e}")))?;
500                let filename = path
501                    .rsplit(['/', '\\'])
502                    .next()
503                    .filter(|s| !s.is_empty())
504                    .unwrap_or("module.wasm")
505                    .to_string();
506                (bytes, filename)
507            }
508            (None, Some(data)) => (data, format!("{}.wasm", upload.name)),
509            // Unreachable — guarded by the XOR check above.
510            _ => unreachable!("exactly one of path/data enforced above"),
511        };
512        if blob.is_empty() {
513            return Err(PulseError::InvalidConfig(
514                "module bytes are empty".to_string(),
515            ));
516        }
517        // Client-side pre-flight validation — mirrors the server's
518        // `ChicoryWasmRunner.validateModule`. Rejecting a non-conforming module
519        // locally avoids a cryptic server 400 / runtime trap.
520        validate_wasm_module(&blob)?;
521
522        let module_part = reqwest::multipart::Part::bytes(blob)
523            .file_name(filename)
524            .mime_str("application/wasm")
525            .map_err(PulseError::Transport)?;
526        let mut form = reqwest::multipart::Form::new()
527            .text("name", upload.name)
528            .part("module", module_part);
529        if let Some(description) = upload.description {
530            form = form.text("description", description);
531        }
532
533        self.client
534            .request_multipart("/api/pulse/wasm-modules", form)
535            .await
536    }
537
538    /// `GET /api/pulse/wasm-modules` — modules registered for the caller's org.
539    pub async fn list(&self) -> Result<Vec<Value>, PulseError> {
540        let result = self
541            .client
542            .request(Method::GET, "/api/pulse/wasm-modules", None::<&()>, true)
543            .await?;
544        Ok(unwrap_list(&result, "modules"))
545    }
546
547    /// `GET /api/pulse/wasm-modules/{name}` — metadata for one module.
548    pub async fn get(&self, name: &str) -> Result<Value, PulseError> {
549        let path = format!("/api/pulse/wasm-modules/{}", encode_path(name));
550        self.client
551            .request(Method::GET, &path, None::<&()>, true)
552            .await
553    }
554
555    /// `DELETE /api/pulse/wasm-modules/{name}` — remove a module (ADMIN).
556    pub async fn delete(&self, name: &str) -> Result<(), PulseError> {
557        let path = format!("/api/pulse/wasm-modules/{}", encode_path(name));
558        self.client
559            .request::<()>(Method::DELETE, &path, None, true)
560            .await?;
561        Ok(())
562    }
563}
564
565// ---------------------------------------------------------------------------
566// UsersResource — client.users()
567// ---------------------------------------------------------------------------
568
569pub struct UsersResource<'c> {
570    pub(crate) client: &'c PulseClient,
571}
572
573impl UsersResource<'_> {
574    /// `GET /api/pulse/users` — every user in the current org.
575    ///
576    /// Requires the caller to have the `USERS_LIST` permission atom (Owner /
577    /// Platform Admin personas by default — see B-105).
578    pub async fn list(&self) -> Result<Vec<Value>, PulseError> {
579        let result = self
580            .client
581            .request(Method::GET, "/api/pulse/users", None::<&()>, true)
582            .await?;
583        Ok(unwrap_list(&result, "users"))
584    }
585}
586
587// ---------------------------------------------------------------------------
588// Helpers
589// ---------------------------------------------------------------------------
590
591/// Extracts a `Vec<Value>` from `result[key]`. Returns an empty Vec for
592/// missing / malformed envelopes — never panics — so callers can iterate
593/// safely.
594fn unwrap_list(result: &Value, key: &str) -> Vec<Value> {
595    result
596        .get(key)
597        .and_then(Value::as_array)
598        .cloned()
599        .unwrap_or_default()
600}
601
602/// URL-encodes a path-param segment so ids containing `/`, spaces, etc.
603/// round-trip safely. Uses the same character set as the `pulse-go`
604/// `url.PathEscape` and `pulse-java` `URLEncoder` — `+` is encoded as `%20`.
605fn encode_path(segment: &str) -> String {
606    let mut out = String::with_capacity(segment.len());
607    for b in segment.bytes() {
608        match b {
609            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
610                out.push(b as char)
611            }
612            _ => out.push_str(&format!("%{b:02X}")),
613        }
614    }
615    out
616}
617
618/// Reads an unsigned LEB128 integer from `bytes` starting at `*pos`, advancing
619/// `*pos` past the consumed bytes. Returns `None` on overrun (the caller maps
620/// that to a [`PulseError::Validation`] "malformed WASM module").
621fn read_uleb128(bytes: &[u8], pos: &mut usize) -> Option<u64> {
622    let mut result: u64 = 0;
623    let mut shift: u32 = 0;
624    loop {
625        let byte = *bytes.get(*pos)?;
626        *pos += 1;
627        result |= u64::from(byte & 0x7f) << shift;
628        if byte & 0x80 == 0 {
629            return Some(result);
630        }
631        shift += 7;
632        // A uleb128 wider than 64 bits is malformed for our purposes.
633        if shift >= 64 {
634            return None;
635        }
636    }
637}
638
639/// Builds a client-side [`PulseError::Validation`] for the WASM upload path so
640/// the caller sees the SDK's validation error (not a cryptic server 400) before
641/// any network call.
642fn wasm_validation_err(message: &str) -> PulseError {
643    PulseError::Validation {
644        path: "/api/pulse/wasm-modules".to_string(),
645        body: Some(json!({ "error": message })),
646    }
647}
648
649/// Client-side pre-upload validation of a WASM module's raw bytes — inspects the
650/// binary (does NOT execute it) and mirrors the server's
651/// `ChicoryWasmRunner.validateModule`.
652///
653/// A conforming sandbox module must: have the WASM magic + version 1 header,
654/// import zero host functions, and export `alloc`, `process`, and `memory`.
655/// Anything else is rejected with [`PulseError::Validation`] so the failure is
656/// surfaced locally instead of as a server 400 / runtime trap.
657pub fn validate_wasm_module(bytes: &[u8]) -> Result<(), PulseError> {
658    if bytes.len() < 8 {
659        return Err(wasm_validation_err("not a WASM module: too short"));
660    }
661    if bytes[0..4] != [0x00, b'a', b's', b'm'] || bytes[4..8] != [0x01, 0x00, 0x00, 0x00] {
662        return Err(wasm_validation_err("not a WASM module (bad magic/version)"));
663    }
664
665    let malformed = || wasm_validation_err("malformed WASM module");
666
667    let mut has_alloc = false;
668    let mut has_process = false;
669    let mut has_memory = false;
670
671    let mut pos = 8usize;
672    while pos < bytes.len() {
673        // Section id (1 byte) + uleb128 payload size.
674        let id = bytes[pos];
675        pos += 1;
676        let size = read_uleb128(bytes, &mut pos).ok_or_else(malformed)? as usize;
677        let payload_start = pos;
678        let payload_end = payload_start.checked_add(size).ok_or_else(malformed)?;
679        if payload_end > bytes.len() {
680            return Err(malformed());
681        }
682
683        match id {
684            // Import section — any host import disqualifies the module.
685            2 => {
686                let mut p = payload_start;
687                let count = read_uleb128(bytes, &mut p).ok_or_else(malformed)?;
688                if count > 0 {
689                    return Err(wasm_validation_err(
690                        "WASM module imports host functions; it must be a pure sandbox \
691                         (build with no WASI/host imports)",
692                    ));
693                }
694            }
695            // Export section — collect exported names.
696            7 => {
697                let mut p = payload_start;
698                let count = read_uleb128(bytes, &mut p).ok_or_else(malformed)?;
699                for _ in 0..count {
700                    let name_len = read_uleb128(bytes, &mut p).ok_or_else(malformed)? as usize;
701                    let name_end = p.checked_add(name_len).ok_or_else(malformed)?;
702                    if name_end > payload_end {
703                        return Err(malformed());
704                    }
705                    let name = &bytes[p..name_end];
706                    p = name_end;
707                    // 1 kind byte + uleb128 index.
708                    if p >= payload_end {
709                        return Err(malformed());
710                    }
711                    p += 1; // kind
712                    read_uleb128(bytes, &mut p).ok_or_else(malformed)?; // index
713                    match name {
714                        b"alloc" => has_alloc = true,
715                        b"process" => has_process = true,
716                        b"memory" => has_memory = true,
717                        _ => {}
718                    }
719                }
720            }
721            _ => {}
722        }
723
724        // Advance past the section payload regardless of id.
725        pos = payload_end;
726    }
727
728    if !(has_alloc && has_process && has_memory) {
729        return Err(wasm_validation_err(
730            "WASM module must export alloc, process and memory",
731        ));
732    }
733    Ok(())
734}
735
736// ---------------------------------------------------------------------------
737// ConnectorsResource — client.connectors() (B-093 follow-up: catalogue parity)
738// ---------------------------------------------------------------------------
739
740/// `client.connectors()` — the connector catalogue, the same list the Pipeline
741/// Studio palette and `pulse connectors list` show. Each entry is
742/// `{subType, displayName, configFields}`; use the `subType` as a sink/source
743/// node `type` in a pipeline definition deployed via `client.pipelines()`.
744/// Bridged connectors appear only when the enterprise bridge JAR is on the
745/// server's classpath.
746pub struct ConnectorsResource<'c> {
747    pub(crate) client: &'c PulseClient,
748}
749
750impl ConnectorsResource<'_> {
751    /// `GET /api/pulse/connectors` — `{"sources": [...], "sinks": [...]}`.
752    pub async fn list(&self) -> Result<Value, PulseError> {
753        self.client
754            .request(Method::GET, "/api/pulse/connectors", None::<&()>, true)
755            .await
756    }
757
758    /// Just the sink connectors.
759    pub async fn sinks(&self) -> Result<Vec<Value>, PulseError> {
760        Ok(unwrap_list(&self.list().await?, "sinks"))
761    }
762
763    /// Just the source connectors.
764    pub async fn sources(&self) -> Result<Vec<Value>, PulseError> {
765        Ok(unwrap_list(&self.list().await?, "sources"))
766    }
767}