Skip to content

Latest commit

 

History

History
258 lines (189 loc) · 6.8 KB

File metadata and controls

258 lines (189 loc) · 6.8 KB

Adding a Connector

Step-by-step guide to building a new first-party connector for Springtale.

1. Decide: Native or WASM

Native WASM
When First-party, audited by the team Community-authored, untrusted
Language Rust TypeScript SDK → wasm32-wasip2, or Rust
Isolation In-process, capability-checked Wasmtime sandbox (10M fuel, 64MB mem, 30s timeout)
Trust High Low

This guide covers native Rust connectors. WASM connector authoring will be covered once the TypeScript SDK lands.


2. Scaffold the Crate

connectors/connector-{name}/
├── Cargo.toml
└── src/
    ├── lib.rs              # pub mod + re-exports ONLY
    ├── config.rs           # Config struct (Deserialize only, Secret<String> for credentials)
    ├── connector.rs        # Connector trait implementation
    ├── error.rs            # Typed error enum (thiserror)
    ├── auth/               # Auth flows (OAuth, API key, bearer)
    │   └── mod.rs
    ├── client/             # Typed HTTP client (all network calls here)
    │   ├── mod.rs
    │   └── api.rs
    ├── triggers/           # One module per trigger type
    │   └── mod.rs
    └── actions/            # One module per action type
        └── mod.rs

Fig. 1. Standard connector directory layout.

2.1. Cargo.toml

[package]
name = "connector-{name}"
version = "0.1.0"
edition = "2024"

[dependencies]
springtale-connector = { workspace = true }
springtale-crypto = { workspace = true }
reqwest = { workspace = true }
secrecy = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
async-trait = { workspace = true }

[dev-dependencies]
tokio = { workspace = true, features = ["test-util"] }

All version pins come from the workspace root Cargo.toml. Never specify versions in connector crates.


3. Implement the Trait

The Connector trait lives in springtale-connector:

#[async_trait]
pub trait Connector: Send + Sync + 'static {
    fn triggers(&self) -> &[TriggerDecl];
    fn actions(&self) -> &[ActionDecl];
    async fn execute(&self, action: &str, input: serde_json::Value)
        -> Result<ActionResult, ConnectorError>;
    async fn on_event(&self, trigger: &str, handler: EventHandler)
        -> Result<(), ConnectorError>;
    fn manifest(&self) -> &ConnectorManifest;
}

Implement this in connector.rs. The struct should hold the config, an HTTP client, and any runtime state (auth tokens, caches).


4. Config with Secrets

Config structs derive Deserialize only — never Serialize (this prevents secrets from appearing in serialized output).

use secrecy::SecretBox;
use serde::Deserialize;

#[derive(Deserialize)]
pub struct MyConfig {
    pub api_key: SecretBox<String>,  // Secret<String> — cannot be logged or serialized
    pub api_base: String,            // Non-secret fields are plain types
    pub timeout_secs: u64,
}

Every credential, token, and password is Secret<String>. Every .expose_secret() call is annotated with // SECURITY: expose needed for X.


5. Write the Client

All network calls go through a typed client in client/. No raw reqwest calls outside this module.

pub struct MyClient {
    http: reqwest::Client,
    api_base: String,
    api_key: SecretBox<String>,
}

impl MyClient {
    pub async fn search(&self, query: &str) -> Result<SearchResult, MyError> {
        let resp = self.http
            .get(format!("{}/search", self.api_base))
            .header("Authorization", format!(
                "Bearer {}",
                self.api_key.expose_secret() // SECURITY: expose for API auth header
            ))
            .query(&[("q", query)])
            .send()
            .await?;
        // ...
    }
}

The reqwest client must use rustls-tls (this is enforced at the workspace level — native-tls is banned).


6. Add Triggers

Triggers declare what events your connector emits. Each trigger has a name and a typed payload schema.

For webhook-based triggers, implement signature verification in your connector (HMAC-SHA256, RSA, etc.). The daemon's /webhook/{connector}/{trigger} endpoint forwards the raw request body to your handler.

For polling/streaming triggers (like Bluesky's Jetstream), implement the subscription loop and call the EventHandler when events arrive.


7. Add Actions

Actions declare what your connector can do. Each action has a name, typed input, and typed output.

Declare the capabilities your actions require in the manifest. The runtime checks capabilities before every execute() call — your action will never run without the required permissions.


8. Write the Manifest

Every connector ships with a TOML manifest:

[connector]
name = "connector-myservice"
version = "0.1.0"
author = "Your Name"
description = "MyService integration for Springtale"

[[capabilities]]
type = "NetworkOutbound"
host = "api.myservice.com"

[[triggers]]
name = "item_created"
description = "Fires when a new item is created"

[[actions]]
name = "create_item"
description = "Create a new item"

[data_disclosure]
description = "Sends item title and body to api.myservice.com"

9. Test

9.1. Unit Tests

Place in the same file as the implementation:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_action_validates_input() {
        // ...
    }

    #[tokio::test]
    async fn test_execute_returns_result() {
        // ...
    }
}

9.2. Mock at the Client Layer

Don't mock reqwest directly. Create a trait for your client and provide a mock implementation for tests. This keeps tests fast and deterministic.

9.3. Test Naming

Pattern: test_{function}_{scenario}_{expected}

#[test]
fn test_search_empty_query_returns_error() { ... }

#[test]
fn test_search_valid_query_returns_results() { ... }

10. Sign and Register

Manifests can be signed with Ed25519 for verification:

# Generate a signing key (or use the vault keypair)
# Sign the manifest
# The signature is verified at install time and on every load

Add the connector to the workspace Cargo.toml:

[workspace]
members = [
    # ...existing members...
    "connectors/connector-myservice",
]

References

  • [1] Connector trait: crates/springtale-connector/src/connector/trait_.rs
  • [2] Capability types: crates/springtale-connector/src/manifest/types.rs
  • [3] Connector guidelines: .claude/rules/connector-guidelines.md
  • [4] Testing conventions: .claude/rules/testing.md
  • [5] Security rules: .claude/rules/security.md
  • [6] Existing connectors for reference: connectors/connector-kick/, connectors/connector-github/