From 2af2a9ed764b8d8c0f69b67d730989a66c0239ab Mon Sep 17 00:00:00 2001 From: Harry Anderson Date: Sat, 21 Feb 2026 19:13:06 +0000 Subject: [PATCH 1/8] Add local-dev Docker Compose environment for rule development Adds a complete local development setup for building and testing Plaid rules without needing the full production infrastructure. Includes multi-stage Dockerfile (Cranelift backend, no LLVM), working configs, hello-world example rule, helper scripts, and documentation covering both local rule development and testing external rules from plaid-rules or company repos via volume mounts. Also fixes chrono pulling in wasm-bindgen on wasm32 targets by disabling default features in plaid-stl, which caused link errors at runtime. --- .dockerignore | 22 ++ local-dev/.gitignore | 9 + local-dev/Dockerfile | 71 ++++ local-dev/README.md | 512 +++++++++++++++++++++++++ local-dev/config/apis.toml | 36 ++ local-dev/config/cache.toml | 9 + local-dev/config/data.toml | 7 + local-dev/config/executor.toml | 2 + local-dev/config/loading.toml | 30 ++ local-dev/config/logging.toml | 1 + local-dev/config/storage.toml | 7 + local-dev/config/webhooks.toml | 23 ++ local-dev/docker-compose.yml | 28 ++ local-dev/rules/.cargo/config.toml | 2 + local-dev/rules/Cargo.lock | 195 ++++++++++ local-dev/rules/Cargo.toml | 9 + local-dev/rules/hello-world/Cargo.toml | 13 + local-dev/rules/hello-world/src/lib.rs | 33 ++ local-dev/scripts/build-modules.sh | 26 ++ local-dev/scripts/test-webhook.sh | 17 + local-dev/secrets/.gitignore | 2 + local-dev/secrets/secrets.toml.example | 37 ++ runtime/plaid-stl/Cargo.toml | 2 +- 23 files changed, 1092 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 local-dev/.gitignore create mode 100644 local-dev/Dockerfile create mode 100644 local-dev/README.md create mode 100644 local-dev/config/apis.toml create mode 100644 local-dev/config/cache.toml create mode 100644 local-dev/config/data.toml create mode 100644 local-dev/config/executor.toml create mode 100644 local-dev/config/loading.toml create mode 100644 local-dev/config/logging.toml create mode 100644 local-dev/config/storage.toml create mode 100644 local-dev/config/webhooks.toml create mode 100644 local-dev/docker-compose.yml create mode 100644 local-dev/rules/.cargo/config.toml create mode 100644 local-dev/rules/Cargo.lock create mode 100644 local-dev/rules/Cargo.toml create mode 100644 local-dev/rules/hello-world/Cargo.toml create mode 100644 local-dev/rules/hello-world/src/lib.rs create mode 100755 local-dev/scripts/build-modules.sh create mode 100755 local-dev/scripts/test-webhook.sh create mode 100644 local-dev/secrets/.gitignore create mode 100644 local-dev/secrets/secrets.toml.example diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..07a5635a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,22 @@ +# Rust build artifacts (these are rebuilt inside the container) +**/target/ + +# Git +.git/ + +# Local dev artifacts that aren't needed in build context +local-dev/secrets/ +local-dev/scripts/ + +# Editor / OS junk +.vscode/ +.idea/ +*.swp +*.swo +.DS_Store + +# Compiled modules (rebuilt in container) +compiled_modules/ + +# Testing artifacts +testing/ diff --git a/local-dev/.gitignore b/local-dev/.gitignore new file mode 100644 index 00000000..c94a559d --- /dev/null +++ b/local-dev/.gitignore @@ -0,0 +1,9 @@ +# Build artifacts +rules/target/ +compiled_modules/ + +# Secrets (real secrets, not the example template) +secrets/secrets.toml + +# Local overrides (per-developer customizations) +docker-compose.override.yml diff --git a/local-dev/Dockerfile b/local-dev/Dockerfile new file mode 100644 index 00000000..578ad3a0 --- /dev/null +++ b/local-dev/Dockerfile @@ -0,0 +1,71 @@ +# Multi-stage Dockerfile for local Plaid development. +# +# Stage 1: Build the plaid runtime (cranelift only — no LLVM needed) +# Stage 2: Build WASM rule modules +# Stage 3: Minimal runtime image + +# ── Stage 1: Build plaid runtime ───────────────────────────────────────────── +FROM rust:1.88.0-slim-bookworm AS runtime-builder + +RUN apt-get update && apt-get install -y \ + build-essential \ + libsodium-dev \ + libssl-dev \ + libzstd-dev \ + libz-dev \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /build +COPY runtime/ runtime/ + +RUN cargo build \ + --manifest-path runtime/Cargo.toml \ + --release \ + --bin=plaid \ + --no-default-features \ + --features=cranelift,sled + +# ── Stage 2: Build WASM modules ───────────────────────────────────────────── +FROM rust:1.88.0-slim-bookworm AS module-builder + +RUN rustup target add wasm32-unknown-unknown + +WORKDIR /build +COPY runtime/plaid-stl/ runtime/plaid-stl/ +COPY local-dev/rules/ local-dev/rules/ + +RUN cargo build \ + --manifest-path local-dev/rules/Cargo.toml \ + --release \ + --target wasm32-unknown-unknown + +# ── Stage 3: Runtime ───────────────────────────────────────────────────────── +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y \ + libsodium23 \ + libssl3 \ + libzstd1 \ + ca-certificates \ + curl \ + && rm -rf /var/lib/apt/lists/* + +RUN useradd -m plaiduser + +# Copy runtime binary +COPY --from=runtime-builder /build/runtime/target/release/plaid /usr/local/bin/plaid + +# Copy compiled WASM modules +COPY --from=module-builder \ + /build/local-dev/rules/target/wasm32-unknown-unknown/release/*.wasm \ + /modules/ + +# Default config + secrets paths (overridable via docker-compose volumes) +RUN mkdir -p /config /secrets /data && chown -R plaiduser:plaiduser /data + +USER plaiduser + +EXPOSE 8080 + +CMD ["plaid", "--config", "/config", "--secrets", "/secrets/secrets.toml"] diff --git a/local-dev/README.md b/local-dev/README.md new file mode 100644 index 00000000..114d2005 --- /dev/null +++ b/local-dev/README.md @@ -0,0 +1,512 @@ +# Plaid Local Development + +Run Plaid locally with Docker Compose. Build and test your own rules with a +fast edit-compile-run feedback loop. + +## Quick Start + +```bash +cd local-dev + +# 1. Set up secrets (optional — skip if you don't need external APIs) +cp secrets/secrets.toml.example secrets/secrets.toml +# Edit secrets/secrets.toml with your API keys + +# 2. Build and start Plaid +docker compose up --build + +# 3. Send a test webhook (in another terminal) +curl -X POST http://localhost:8080/webhook/hello \ + -H "Content-Type: application/json" \ + -d '{"message": "hello from local dev"}' +``` + +You should see the hello-world rule log the payload in the Docker output: + +``` +plaid | [hello-world] source: webhook POST +plaid | [hello-world] payload: {"message": "hello from local dev"} +plaid | [hello-world] parsed JSON with 1 keys +``` + +## Project Structure + +``` +local-dev/ +├── docker-compose.yml # Orchestrates the local Plaid runtime +├── Dockerfile # Multi-stage: build runtime + modules +├── config/ # Plaid configuration (TOML) +│ ├── apis.toml # External API definitions +│ ├── cache.toml # Cache backend (InMemory default) +│ ├── data.toml # Data generators (cron, SQS, etc.) +│ ├── executor.toml # Thread pool configuration +│ ├── loading.toml # Module loading and limits +│ ├── logging.toml # Log sinks (stdout default) +│ ├── storage.toml # Persistent storage backend +│ └── webhooks.toml # Webhook endpoints and routing +├── secrets/ +│ ├── .gitignore # Prevents committing real secrets +│ └── secrets.toml.example # Template — copy to secrets.toml +├── rules/ # Your WASM rule source code +│ ├── Cargo.toml # Workspace — add your rules here +│ ├── .cargo/config.toml # Sets wasm32-unknown-unknown target +│ └── hello-world/ # Example rule +│ ├── Cargo.toml +│ └── src/lib.rs +├── scripts/ +│ ├── build-modules.sh # Build modules locally (no Docker) +│ └── test-webhook.sh # Quick webhook test helper +└── README.md +``` + +## Writing a New Rule + +### 1. Create the rule crate + +```bash +mkdir -p rules/my-rule/src +``` + +**rules/my-rule/Cargo.toml:** +```toml +[package] +name = "my_rule" +description = "What this rule does" +version = "0.1.0" +edition = "2021" + +[dependencies] +plaid_stl = { path = "../../../runtime/plaid-stl" } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1" + +[lib] +crate-type = ["cdylib"] +``` + +**rules/my-rule/src/lib.rs:** +```rust +use plaid_stl::{entrypoint_with_source, messages::LogSource, plaid}; + +entrypoint_with_source!(); + +fn main(data: String, source: LogSource) -> Result<(), i32> { + plaid::print_debug_string(&format!("Got: {data}")); + Ok(()) +} +``` + +### 2. Register it in the workspace + +Edit `rules/Cargo.toml`: +```toml +[workspace] +members = [ + "hello-world", + "my-rule", # Add your rule here +] +``` + +### 3. Add a webhook route + +Edit `config/webhooks.toml`: +```toml +[webhooks."local".webhooks."my-endpoint"] +log_type = "my_rule" +headers = [] +logbacks_allowed = "Unlimited" +``` + +The `log_type` must match the WASM filename (underscores, not hyphens). +`my_rule.wasm` -> `log_type = "my_rule"`. + +### 4. Rebuild and test + +```bash +docker compose up --build +curl -X POST http://localhost:8080/webhook/my-endpoint -d 'test payload' +``` + +## Testing External Rules (plaid-rules, company repos) + +If you develop rules in a separate repository (e.g., `plaid-rules`), you can +test them against the local Plaid runtime without copying files around. + +### Option A: Volume-mount pre-compiled WASM (fastest iteration) + +Build your rules locally, then mount the compiled `.wasm` files into the +running container. No Docker rebuild needed — just restart. + +**1. Build your rules to WASM:** + +```bash +# In your rules repo (e.g., ~/dev/plaid-rules) +cd ~/dev/plaid-rules +cargo build --release --target wasm32-unknown-unknown +``` + +If your rules workspace doesn't have a `.cargo/config.toml` setting the +default target, you'll need the `--target` flag every time. You can add one: + +```toml +# .cargo/config.toml +[build] +target = "wasm32-unknown-unknown" +``` + +**2. Mount the compiled modules into the container:** + +Create a `docker-compose.override.yml` in `local-dev/` (this file is +gitignored and won't affect other developers): + +```yaml +# docker-compose.override.yml +services: + plaid: + volumes: + - /path/to/plaid-rules/target/wasm32-unknown-unknown/release:/external-modules:ro +``` + +**3. Update `loading.toml` to load from both directories:** + +The Plaid runtime loads modules from a single `module_dir`. To load external +modules, mount them into the same `/modules` directory. Update the override: + +```yaml +# docker-compose.override.yml +services: + plaid: + volumes: + # Mount individual .wasm files into /modules alongside built-in modules + - /path/to/plaid-rules/target/wasm32-unknown-unknown/release/my_rule.wasm:/modules/my_rule.wasm:ro +``` + +Or use a script to copy the `.wasm` files you want into `compiled_modules/` +and mount that directory: + +```bash +# Copy specific rules you want to test +mkdir -p compiled_modules +cp ~/dev/plaid-rules/target/wasm32-unknown-unknown/release/my_rule.wasm compiled_modules/ +cp ~/dev/plaid-rules/target/wasm32-unknown-unknown/release/another_rule.wasm compiled_modules/ +``` + +```yaml +# docker-compose.override.yml +services: + plaid: + volumes: + - ./compiled_modules:/modules:ro +``` + +Note: when you mount over `/modules`, the built-in hello-world module is +hidden. If you want both, copy it in too or use the build script. + +**4. Add webhook routes and log type overrides as needed:** + +Edit `config/webhooks.toml` and `config/loading.toml` for your external rules, +then restart: + +```bash +docker compose restart +``` + +No `--build` needed — config and modules are volume-mounted. + +### Option B: Symlink rules into the workspace (rebuilds in Docker) + +If you want Docker to compile your external rules, symlink them into the +`local-dev/rules/` workspace. + +**1. Symlink the rule crate:** + +```bash +cd local-dev/rules +ln -s /path/to/plaid-rules/rules/my-rule my-rule +``` + +**2. Register in the workspace and add plaid-stl path override:** + +Edit `rules/Cargo.toml`: +```toml +[workspace] +members = [ + "hello-world", + "my-rule", +] +``` + +Your external rule's `Cargo.toml` probably references `plaid_stl` with a +different relative path. Add a path override to fix this without modifying +the external repo: + +```toml +# rules/Cargo.toml +[workspace] +members = [ + "hello-world", + "my-rule", +] +resolver = "2" + +[patch.crates-io] +plaid_stl = { path = "../../runtime/plaid-stl" } +``` + +Or if the external rule uses a relative path dep, the symlink may just +resolve correctly if the directory structure aligns. Test with: + +```bash +cd rules && cargo check --target wasm32-unknown-unknown +``` + +**3. Rebuild:** + +```bash +docker compose up --build +``` + +### Option C: Build script (recommended for regular use) + +Create a small script that builds your rules and stages them for the +container: + +```bash +#!/usr/bin/env bash +# dev.sh — build external rules and start Plaid +set -euo pipefail + +RULES_REPO="${1:-$HOME/dev/plaid-rules}" +MODULES_DIR="$(dirname "$0")/compiled_modules" + +mkdir -p "$MODULES_DIR" + +# Build external rules +echo "Building rules from $RULES_REPO..." +cargo build \ + --manifest-path "$RULES_REPO/Cargo.toml" \ + --release \ + --target wasm32-unknown-unknown + +# Copy compiled modules +for wasm in "$RULES_REPO/target/wasm32-unknown-unknown/release/"*.wasm; do + [ -f "$wasm" ] || continue + name=$(basename "$wasm") + cp "$wasm" "$MODULES_DIR/$name" + echo " Staged: $name" +done + +# Also build local rules +echo "Building local rules..." +./scripts/build-modules.sh + +# Start Plaid with both local and external modules +docker compose up "$@" +``` + +Then use `docker-compose.override.yml` to mount `compiled_modules/`: + +```yaml +services: + plaid: + volumes: + - ./compiled_modules:/modules:ro +``` + +### Quick reference: iteration loop + +```bash +# Edit your rule in ~/dev/plaid-rules/rules/my-rule/src/lib.rs +# Then: +cd ~/dev/plaid-rules +cargo build --release --target wasm32-unknown-unknown +cp target/wasm32-unknown-unknown/release/my_rule.wasm ~/dev/plaid/local-dev/compiled_modules/ + +# In the local-dev directory: +docker compose restart # picks up new .wasm +./scripts/test-webhook.sh my-endpoint # test it +``` + +## Adding Secrets + +Secrets let your rules call external APIs (Slack, GitHub, AWS, etc.) +without hardcoding credentials in config files. + +### 1. Create secrets file + +```bash +cp secrets/secrets.toml.example secrets/secrets.toml +``` + +### 2. Add your secret values + +```toml +# secrets/secrets.toml +"slack-webhook-url" = "https://hooks.slack.com/services/T.../B.../..." +"slack-bot-token" = "xoxb-1234-5678-abcdef" +``` + +### 3. Reference secrets in config + +In `config/apis.toml`, use the `{plaid-secret{key}}` syntax: + +```toml +[apis."slack"] +[apis."slack".webhooks] +alerts = "{plaid-secret{slack-webhook-url}}" +[apis."slack".bot_tokens] +mybot = "{plaid-secret{slack-bot-token}}" +``` + +### 4. Grant your rule access + +Some APIs require explicit rule allowlisting in the config. For example, +named HTTP requests need `allowed_rules`: + +```toml +[apis."general".network.web_requests."my_api_call"] +verb = "post" +uri = "https://api.example.com/endpoint" +return_body = true +return_code = true +allowed_rules = ["my_rule.wasm"] +[apis."general".network.web_requests."my_api_call".headers] +Authorization = "Bearer {plaid-secret{my-api-token}}" +``` + +**Secrets are never committed to git.** The `secrets/` directory has a +`.gitignore` that excludes `secrets.toml`. Only the `.example` template is +tracked. + +## Available plaid-stl APIs + +Rules can use these APIs from `plaid_stl`: + +| Module | Functions | +|--------|-----------| +| `plaid` | `print_debug_string`, `log_back`, `get_time`, `get_secrets`, `get_headers` | +| `plaid::storage` | `insert`, `get`, `delete` (per-rule key-value store) | +| `plaid::cache` | `insert`, `get` (in-memory cache with TTL) | +| `network` | `make_named_request`, `simple_json_post_request` | +| `slack` | `post_message`, `create_channel`, `invite_to_channel`, `get_id_from_email` | +| `github` | `graphql`, `get_id_from_username`, repo/PR/team operations | +| `aws::dynamodb` | DynamoDB read/write | +| `aws::kms` | KMS encrypt/decrypt/sign | +| `aws::s3` | S3 get/put | +| `gcp::google_docs` | Google Docs/Sheets creation | +| `jira` | Issue creation and updates | +| `okta` | User/group management | +| `pagerduty` | Incident creation | +| `splunk` | Log ingestion | +| `cryptography` | AES encryption, JWT validation | + +Each API must be configured in `config/apis.toml` with credentials in +`secrets/secrets.toml`. Rules that don't use external APIs (logging, +parsing, storage, cache) work out of the box. + +## Entry Point Macros + +Plaid provides three entry point macros depending on your use case: + +```rust +// Process webhook data, no response needed +entrypoint_with_source!(); +fn main(data: String, source: LogSource) -> Result<(), i32> { ... } + +// Process webhook data and return a response +entrypoint_with_source_and_response!(); +fn main(data: String, source: LogSource) -> Result, i32> { ... } + +// Process binary data +entrypoint_vec_with_source!(); +fn main(data: Vec, source: LogSource) -> Result<(), i32> { ... } +``` + +## Log Type Mapping + +Plaid maps WASM module filenames to log types using this logic: + +1. If `loading.toml` has a `log_type_overrides` entry for the filename, use that +2. Otherwise, take everything **before the first underscore** in the filename + +Examples: + +| Filename | Default log type | Override needed? | +|----------|-----------------|------------------| +| `scanner.wasm` | `scanner` | No | +| `push_patrol.wasm` | `push` | Yes — add `"push_patrol.wasm" = "push_patrol"` | +| `hello_world.wasm` | `hello` | Yes — add `"hello_world.wasm" = "hello_world"` | + +If your rule name has underscores, add an explicit override in +`config/loading.toml`: + +```toml +[loading.log_type_overrides] +"my_rule.wasm" = "my_rule" +``` + +## Running Without Docker + +If you have Rust installed locally: + +```bash +# Install WASM target +rustup target add wasm32-unknown-unknown + +# Build modules +./scripts/build-modules.sh + +# Build and run the runtime (from repo root) +cd ../runtime +RUST_LOG=plaid=debug cargo run --bin=plaid \ + --no-default-features --features=cranelift,sled \ + -- --config ../local-dev/config --secrets ../local-dev/secrets/secrets.toml +``` + +## Webhook Reference + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/webhook/hello` | Triggers `hello_world` rule | +| POST | `/webhook/default` | Triggers any rule with `log_type = "default"` | +| GET | `/webhook/health` | Returns `ok` (healthcheck) | + +Add more endpoints in `config/webhooks.toml`. + +## Troubleshooting + +**"No module found for log type"** — The WASM filename must match the +`log_type` in `webhooks.toml`. Check that underscores/hyphens match. If +your crate name has underscores, add a `log_type_overrides` entry in +`config/loading.toml` (see [Log Type Mapping](#log-type-mapping)). + +**"API not configured"** — The rule is calling an API that isn't defined in +`config/apis.toml`. Add the API config and restart. + +**"secrets file not found"** — Create `secrets/secrets.toml` (can be empty): +```bash +touch secrets/secrets.toml +``` + +**"missing field" parse errors** — The Plaid runtime requires certain config +fields even when empty. If you see `missing field 'foo'`, add an empty TOML +table for it (e.g., `[loading.foo]`). + +**`wasm-bindgen` link errors** — A dependency is pulling in `wasm-bindgen`, +which requires a JS host. Fix by disabling default features on the offending +crate (commonly `chrono`): +```toml +chrono = { version = "0.4", default-features = false, features = ["serde"] } +``` + +**Docker Compose hangs on Ctrl+C** — The `docker-compose.yml` includes +`init: true` which forwards signals properly. If using an older version, +add it to your service definition. + +**Build context too large / slow builds** — Make sure `.dockerignore` exists +at the repo root and excludes `**/target/` and `.git/`. Without it, Docker +sends multi-GB Rust build artifacts as context. + +**Module changes not picking up** — If modules are baked into the image, +rebuild: `docker compose up --build`. If modules are volume-mounted, +a restart is enough: `docker compose restart`. diff --git a/local-dev/config/apis.toml b/local-dev/config/apis.toml new file mode 100644 index 00000000..7eec4837 --- /dev/null +++ b/local-dev/config/apis.toml @@ -0,0 +1,36 @@ +# API configuration for local development. +# +# To add external API integrations, define them here and add +# corresponding secrets to secrets/secrets.toml. +# +# See the full config reference in runtime/plaid/resources/jrp_config/apis.toml +# for examples of GitHub, Slack, AWS, GCP, blockchain, and other integrations. + +[apis."general"] +[apis."general".network] +[apis."general".network.web_requests] + +# Example: allow a rule to make HTTP GET requests +# [apis."general".network.web_requests."example_request"] +# verb = "get" +# uri = "https://httpbin.org/get" +# return_body = true +# return_code = true +# return_cert = false +# allowed_rules = ["hello_world.wasm"] +# [apis."general".network.web_requests."example_request".headers] + +# Example: Slack integration +# [apis."slack"] +# [apis."slack".webhooks] +# alerts = "{plaid-secret{slack-webhook-url}}" +# [apis."slack".bot_tokens] +# mybot = "{plaid-secret{slack-bot-token}}" + +# Example: GitHub integration (GitHub App) +# [apis."github"] +# [apis."github".authentication] +# app_id = 123456 +# installation_id = 654321 +# private_key = """{plaid-secret{github-app-private-key}}""" +# [apis."github".graphql_queries] diff --git a/local-dev/config/cache.toml b/local-dev/config/cache.toml new file mode 100644 index 00000000..819cca43 --- /dev/null +++ b/local-dev/config/cache.toml @@ -0,0 +1,9 @@ +[cache] + +[cache.cache_entries] +default = 200 +[cache.cache_entries.log_type] +[cache.cache_entries.module_overrides] + +[cache.backend] +type = "InMemory" diff --git a/local-dev/config/data.toml b/local-dev/config/data.toml new file mode 100644 index 00000000..e64bdf5d --- /dev/null +++ b/local-dev/config/data.toml @@ -0,0 +1,7 @@ +[data] + +# Example: cron job that fires every 30 seconds +# [data.interval] +# [data.interval.jobs."my_cron_job"] +# schedule = "0,30 * * * * * *" +# log_type = "my_cron_rule" diff --git a/local-dev/config/executor.toml b/local-dev/config/executor.toml new file mode 100644 index 00000000..39e85024 --- /dev/null +++ b/local-dev/config/executor.toml @@ -0,0 +1,2 @@ +[executor] +execution_threads = 2 diff --git a/local-dev/config/loading.toml b/local-dev/config/loading.toml new file mode 100644 index 00000000..a5ed23f7 --- /dev/null +++ b/local-dev/config/loading.toml @@ -0,0 +1,30 @@ +[loading] +module_dir = "/modules" +compiler_backend = "cranelift" +test_mode = false + +[loading.secrets] + +[loading.log_type_overrides] +"hello_world.wasm" = "hello_world" + +[loading.accessory_data_log_type_overrides] + +[loading.accessory_data_file_overrides] + +[loading.persistent_response_size] + +[loading.computation_amount] +default = 55_000_000 +[loading.computation_amount.log_type] +[loading.computation_amount.module_overrides] + +[loading.memory_page_count] +default = 300 +[loading.memory_page_count.log_type] +[loading.memory_page_count.module_overrides] + +[loading.storage_size] +default = "Unlimited" +[loading.storage_size.log_type] +[loading.storage_size.module_overrides] diff --git a/local-dev/config/logging.toml b/local-dev/config/logging.toml new file mode 100644 index 00000000..4193a9d0 --- /dev/null +++ b/local-dev/config/logging.toml @@ -0,0 +1 @@ +[logging."stdout"] diff --git a/local-dev/config/storage.toml b/local-dev/config/storage.toml new file mode 100644 index 00000000..14354eae --- /dev/null +++ b/local-dev/config/storage.toml @@ -0,0 +1,7 @@ +[storage.db] +type = "InMemory" + +# For persistent storage across restarts, use Sled: +# [storage.db] +# type = "Sled" +# sled_path = "/data/sled" diff --git a/local-dev/config/webhooks.toml b/local-dev/config/webhooks.toml new file mode 100644 index 00000000..3a81caea --- /dev/null +++ b/local-dev/config/webhooks.toml @@ -0,0 +1,23 @@ +[webhooks."local"] +listen_address = "0.0.0.0:8080" + +# Default webhook — receives POST requests at /webhook/default +# Triggers any rule with log_type "default" +[webhooks."local".webhooks."default"] +log_type = "default" +headers = ["Content-Type", "Authorization", "X-Custom-Header"] +logbacks_allowed = "Unlimited" + +# Hello world webhook — receives POST requests at /webhook/hello +# Triggers the hello_world rule +[webhooks."local".webhooks."hello"] +log_type = "hello_world" +headers = [] +logbacks_allowed = "Unlimited" + +# GET handler example — returns static text at GET /webhook/health +[webhooks."local".webhooks."health"] +log_type = "health" +headers = [] +[webhooks."local".webhooks."health".get_mode] +response_mode = "static:ok" diff --git a/local-dev/docker-compose.yml b/local-dev/docker-compose.yml new file mode 100644 index 00000000..bf1394a3 --- /dev/null +++ b/local-dev/docker-compose.yml @@ -0,0 +1,28 @@ +services: + plaid: + build: + context: .. + dockerfile: local-dev/Dockerfile + ports: + - "8080:8080" + volumes: + # Config files — edit these to configure APIs, webhooks, etc. + - ./config:/config:ro + # Secrets — copy secrets.toml.example to secrets.toml and add your keys + - ./secrets:/secrets:ro + # Persistent storage (when using Sled backend) + - plaid-data:/data + environment: + - RUST_LOG=plaid=debug + init: true + stop_grace_period: 5s + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-sf", "http://localhost:8080/webhook/health"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 30s + +volumes: + plaid-data: diff --git a/local-dev/rules/.cargo/config.toml b/local-dev/rules/.cargo/config.toml new file mode 100644 index 00000000..f4e8c002 --- /dev/null +++ b/local-dev/rules/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +target = "wasm32-unknown-unknown" diff --git a/local-dev/rules/Cargo.lock b/local-dev/rules/Cargo.lock new file mode 100644 index 00000000..8d74067f --- /dev/null +++ b/local-dev/rules/Cargo.lock @@ -0,0 +1,195 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "num-traits", + "serde", +] + +[[package]] +name = "hello_world" +version = "0.1.0" +dependencies = [ + "plaid_stl", + "serde", + "serde_json", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "plaid_stl" +version = "0.35.14" +dependencies = [ + "base64", + "chrono", + "paste", + "regex", + "serde", + "serde_json", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/local-dev/rules/Cargo.toml b/local-dev/rules/Cargo.toml new file mode 100644 index 00000000..c53c8047 --- /dev/null +++ b/local-dev/rules/Cargo.toml @@ -0,0 +1,9 @@ +[workspace] +members = [ + "hello-world", +] +resolver = "2" + +[profile.release] +lto = true +strip = "symbols" diff --git a/local-dev/rules/hello-world/Cargo.toml b/local-dev/rules/hello-world/Cargo.toml new file mode 100644 index 00000000..72c84b15 --- /dev/null +++ b/local-dev/rules/hello-world/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "hello_world" +description = "Minimal example plaid rule — logs the incoming webhook payload" +version = "0.1.0" +edition = "2021" + +[dependencies] +plaid_stl = { path = "../../../runtime/plaid-stl" } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1" + +[lib] +crate-type = ["cdylib"] diff --git a/local-dev/rules/hello-world/src/lib.rs b/local-dev/rules/hello-world/src/lib.rs new file mode 100644 index 00000000..cec7b262 --- /dev/null +++ b/local-dev/rules/hello-world/src/lib.rs @@ -0,0 +1,33 @@ +use plaid_stl::{entrypoint_with_source, messages::LogSource, plaid}; + +entrypoint_with_source!(); + +fn main(data: String, source: LogSource) -> Result<(), i32> { + let source_type = match &source { + LogSource::WebhookPost(_) => "webhook POST", + LogSource::WebhookGet(_) => "webhook GET", + LogSource::Logback(_) => "logback", + _ => "unknown", + }; + + plaid::print_debug_string(&format!("[hello-world] source: {source_type}")); + plaid::print_debug_string(&format!("[hello-world] payload: {data}")); + + // Parse as JSON if possible, otherwise treat as plain text + match serde_json::from_str::(&data) { + Ok(json) => { + plaid::print_debug_string(&format!( + "[hello-world] parsed JSON with {} keys", + json.as_object().map(|o| o.len()).unwrap_or(0) + )); + } + Err(_) => { + plaid::print_debug_string(&format!( + "[hello-world] plain text ({} bytes)", + data.len() + )); + } + } + + Ok(()) +} diff --git a/local-dev/scripts/build-modules.sh b/local-dev/scripts/build-modules.sh new file mode 100755 index 00000000..efd6c73e --- /dev/null +++ b/local-dev/scripts/build-modules.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Build WASM modules locally (without Docker). +# Requires: rustup target add wasm32-unknown-unknown +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +RULES_DIR="$SCRIPT_DIR/../rules" +OUT_DIR="$SCRIPT_DIR/../compiled_modules" + +mkdir -p "$OUT_DIR" + +echo "Building WASM modules..." +cargo build \ + --manifest-path "$RULES_DIR/Cargo.toml" \ + --release \ + --target wasm32-unknown-unknown + +# Copy all .wasm files to output directory +for wasm in "$RULES_DIR/target/wasm32-unknown-unknown/release/"*.wasm; do + [ -f "$wasm" ] || continue + name=$(basename "$wasm") + cp "$wasm" "$OUT_DIR/$name" + echo " $name ($(du -h "$wasm" | cut -f1))" +done + +echo "Done. Modules in $OUT_DIR/" diff --git a/local-dev/scripts/test-webhook.sh b/local-dev/scripts/test-webhook.sh new file mode 100755 index 00000000..88d4d165 --- /dev/null +++ b/local-dev/scripts/test-webhook.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Send a test webhook to the local plaid instance. +set -euo pipefail + +HOST="${PLAID_HOST:-localhost:8080}" +ENDPOINT="${1:-hello}" +PAYLOAD="${2:-{"message": "hello from local dev"}}" + +echo "POST http://$HOST/webhook/$ENDPOINT" +echo "Payload: $PAYLOAD" +echo "---" + +curl -s -w "\nHTTP %{http_code}\n" \ + -X POST \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" \ + "http://$HOST/webhook/$ENDPOINT" diff --git a/local-dev/secrets/.gitignore b/local-dev/secrets/.gitignore new file mode 100644 index 00000000..f3041c64 --- /dev/null +++ b/local-dev/secrets/.gitignore @@ -0,0 +1,2 @@ +# Never commit real secrets +secrets.toml diff --git a/local-dev/secrets/secrets.toml.example b/local-dev/secrets/secrets.toml.example new file mode 100644 index 00000000..cdc3991f --- /dev/null +++ b/local-dev/secrets/secrets.toml.example @@ -0,0 +1,37 @@ +# Plaid Secrets — Local Development +# +# Copy this file to secrets.toml and fill in values for any APIs you +# want to use. Referenced in config/apis.toml as {plaid-secret{key-name}}. +# +# This file is gitignored — your secrets will never be committed. + +# Slack +# "slack-webhook-url" = "https://hooks.slack.com/services/T.../B.../..." +# "slack-bot-token" = "xoxb-..." + +# GitHub App +# "github-app-private-key" = """-----BEGIN RSA PRIVATE KEY----- +# ... +# -----END RSA PRIVATE KEY-----""" + +# GitHub Personal Access Token +# "github-pat" = "ghp_..." + +# PagerDuty +# "pagerduty-integration-key" = "..." + +# Splunk HEC +# "splunk-token" = "Splunk ..." + +# AWS (if not using IAM roles) +# "aws-access-key-id" = "AKIA..." +# "aws-secret-access-key" = "..." + +# GCP Service Account (for Google Docs/Sheets) +# "gcp-service-account-key" = """{ "type": "service_account", ... }""" + +# Okta +# "okta-client-id" = "0oa..." +# "okta-private-key" = """-----BEGIN PRIVATE KEY----- +# ... +# -----END PRIVATE KEY-----""" diff --git a/runtime/plaid-stl/Cargo.toml b/runtime/plaid-stl/Cargo.toml index 138e4a52..11604bb2 100644 --- a/runtime/plaid-stl/Cargo.toml +++ b/runtime/plaid-stl/Cargo.toml @@ -7,7 +7,7 @@ edition = "2021" [dependencies] base64 = "0.13" -chrono = { version = "0.4", features = ["serde"] } +chrono = { version = "0.4", default-features = false, features = ["serde"] } paste = "1" regex = "1.11.0" serde = { version = "1", features = ["derive"] } From 14d29edbdf71a0e92c1dedf66d8450b2494e7bdf Mon Sep 17 00:00:00 2001 From: Harry Anderson Date: Sat, 21 Feb 2026 20:05:44 +0000 Subject: [PATCH 2/8] feat: local-dev config defaults, log type warning, dev tooling Add #[serde(default)] to HashMap/Vec config fields so minimal configs work without empty table boilerplate. Warn when log type inference truncates at underscores. Add docker-compose.dev.yml for external module mounts and watch-modules.sh for auto-restart. --- local-dev/README.md | 89 ++++++++++------------- local-dev/config/apis.toml | 2 - local-dev/config/loading.toml | 14 ---- local-dev/docker-compose.dev.yml | 13 ++++ local-dev/scripts/watch-modules.sh | 49 +++++++++++++ runtime/plaid/src/apis/general/mod.rs | 1 + runtime/plaid/src/apis/general/network.rs | 3 +- runtime/plaid/src/loader/mod.rs | 19 +++++ 8 files changed, 122 insertions(+), 68 deletions(-) create mode 100644 local-dev/docker-compose.dev.yml create mode 100755 local-dev/scripts/watch-modules.sh diff --git a/local-dev/README.md b/local-dev/README.md index 114d2005..ce3a437b 100644 --- a/local-dev/README.md +++ b/local-dev/README.md @@ -34,6 +34,7 @@ plaid | [hello-world] parsed JSON with 1 keys ``` local-dev/ ├── docker-compose.yml # Orchestrates the local Plaid runtime +├── docker-compose.dev.yml # Compose override for external modules ├── Dockerfile # Multi-stage: build runtime + modules ├── config/ # Plaid configuration (TOML) │ ├── apis.toml # External API definitions @@ -55,7 +56,8 @@ local-dev/ │ └── src/lib.rs ├── scripts/ │ ├── build-modules.sh # Build modules locally (no Docker) -│ └── test-webhook.sh # Quick webhook test helper +│ ├── test-webhook.sh # Quick webhook test helper +│ └── watch-modules.sh # Watch .wasm files and auto-restart └── README.md ``` @@ -154,65 +156,49 @@ default target, you'll need the `--target` flag every time. You can add one: target = "wasm32-unknown-unknown" ``` -**2. Mount the compiled modules into the container:** - -Create a `docker-compose.override.yml` in `local-dev/` (this file is -gitignored and won't affect other developers): - -```yaml -# docker-compose.override.yml -services: - plaid: - volumes: - - /path/to/plaid-rules/target/wasm32-unknown-unknown/release:/external-modules:ro -``` - -**3. Update `loading.toml` to load from both directories:** - -The Plaid runtime loads modules from a single `module_dir`. To load external -modules, mount them into the same `/modules` directory. Update the override: - -```yaml -# docker-compose.override.yml -services: - plaid: - volumes: - # Mount individual .wasm files into /modules alongside built-in modules - - /path/to/plaid-rules/target/wasm32-unknown-unknown/release/my_rule.wasm:/modules/my_rule.wasm:ro -``` - -Or use a script to copy the `.wasm` files you want into `compiled_modules/` -and mount that directory: +**2. Copy `.wasm` files into `compiled_modules/`:** ```bash -# Copy specific rules you want to test mkdir -p compiled_modules cp ~/dev/plaid-rules/target/wasm32-unknown-unknown/release/my_rule.wasm compiled_modules/ cp ~/dev/plaid-rules/target/wasm32-unknown-unknown/release/another_rule.wasm compiled_modules/ ``` -```yaml -# docker-compose.override.yml -services: - plaid: - volumes: - - ./compiled_modules:/modules:ro -``` - Note: when you mount over `/modules`, the built-in hello-world module is hidden. If you want both, copy it in too or use the build script. +**3. Start with the dev compose file:** + +```bash +docker compose -f docker-compose.yml -f docker-compose.dev.yml up +``` + +This uses `docker-compose.dev.yml` which mounts `compiled_modules/` into +the container at `/modules`. No custom override file needed. + **4. Add webhook routes and log type overrides as needed:** Edit `config/webhooks.toml` and `config/loading.toml` for your external rules, then restart: ```bash -docker compose restart +docker compose -f docker-compose.yml -f docker-compose.dev.yml restart ``` No `--build` needed — config and modules are volume-mounted. +**5. (Optional) Auto-restart on module changes:** + +Use the watch script to automatically restart the container when `.wasm` +files in `compiled_modules/` change: + +```bash +./scripts/watch-modules.sh compiled_modules +``` + +This uses `inotifywait` if available, otherwise falls back to polling. +Re-compile your rules and the container restarts automatically. + ### Option B: Symlink rules into the workspace (rebuilds in Docker) If you want Docker to compile your external rules, symlink them into the @@ -304,13 +290,10 @@ echo "Building local rules..." docker compose up "$@" ``` -Then use `docker-compose.override.yml` to mount `compiled_modules/`: +Then use `docker-compose.dev.yml` to mount `compiled_modules/`: -```yaml -services: - plaid: - volumes: - - ./compiled_modules:/modules:ro +```bash +docker compose -f docker-compose.yml -f docker-compose.dev.yml up ``` ### Quick reference: iteration loop @@ -323,8 +306,11 @@ cargo build --release --target wasm32-unknown-unknown cp target/wasm32-unknown-unknown/release/my_rule.wasm ~/dev/plaid/local-dev/compiled_modules/ # In the local-dev directory: -docker compose restart # picks up new .wasm -./scripts/test-webhook.sh my-endpoint # test it +docker compose -f docker-compose.yml -f docker-compose.dev.yml restart # picks up new .wasm +./scripts/test-webhook.sh my-endpoint # test it + +# Or use the watch script to auto-restart on changes: +./scripts/watch-modules.sh compiled_modules ``` ## Adding Secrets @@ -488,9 +474,10 @@ your crate name has underscores, add a `log_type_overrides` entry in touch secrets/secrets.toml ``` -**"missing field" parse errors** — The Plaid runtime requires certain config -fields even when empty. If you see `missing field 'foo'`, add an empty TOML -table for it (e.g., `[loading.foo]`). +**"missing field" parse errors** — Most HashMap/Vec config fields default to +empty, but some fields (like `computation_amount`, `memory_page_count`, +`storage_size`) are still required. If you see `missing field 'foo'`, add the +field to your config (e.g., `[loading.foo]`). **`wasm-bindgen` link errors** — A dependency is pulling in `wasm-bindgen`, which requires a JS host. Fix by disabling default features on the offending diff --git a/local-dev/config/apis.toml b/local-dev/config/apis.toml index 7eec4837..c7f6f4fb 100644 --- a/local-dev/config/apis.toml +++ b/local-dev/config/apis.toml @@ -7,8 +7,6 @@ # for examples of GitHub, Slack, AWS, GCP, blockchain, and other integrations. [apis."general"] -[apis."general".network] -[apis."general".network.web_requests] # Example: allow a rule to make HTTP GET requests # [apis."general".network.web_requests."example_request"] diff --git a/local-dev/config/loading.toml b/local-dev/config/loading.toml index a5ed23f7..2c8f9971 100644 --- a/local-dev/config/loading.toml +++ b/local-dev/config/loading.toml @@ -3,28 +3,14 @@ module_dir = "/modules" compiler_backend = "cranelift" test_mode = false -[loading.secrets] - [loading.log_type_overrides] "hello_world.wasm" = "hello_world" -[loading.accessory_data_log_type_overrides] - -[loading.accessory_data_file_overrides] - -[loading.persistent_response_size] - [loading.computation_amount] default = 55_000_000 -[loading.computation_amount.log_type] -[loading.computation_amount.module_overrides] [loading.memory_page_count] default = 300 -[loading.memory_page_count.log_type] -[loading.memory_page_count.module_overrides] [loading.storage_size] default = "Unlimited" -[loading.storage_size.log_type] -[loading.storage_size.module_overrides] diff --git a/local-dev/docker-compose.dev.yml b/local-dev/docker-compose.dev.yml new file mode 100644 index 00000000..f0097367 --- /dev/null +++ b/local-dev/docker-compose.dev.yml @@ -0,0 +1,13 @@ +# Use with: docker compose -f docker-compose.yml -f docker-compose.dev.yml up +# Mounts compiled_modules/ into the container for testing external rules. +# +# Build your rules locally: +# cargo build --release --target wasm32-unknown-unknown +# cp target/wasm32-unknown-unknown/release/my_rule.wasm compiled_modules/ +# +# Then start with both compose files: +# docker compose -f docker-compose.yml -f docker-compose.dev.yml up +services: + plaid: + volumes: + - ./compiled_modules:/modules:ro diff --git a/local-dev/scripts/watch-modules.sh b/local-dev/scripts/watch-modules.sh new file mode 100755 index 00000000..5e721320 --- /dev/null +++ b/local-dev/scripts/watch-modules.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Watch a directory for .wasm file changes and restart the plaid container. +# Runs on the host (not inside Docker). +# +# Usage: +# ./scripts/watch-modules.sh [directory] +# +# Default directory: ./compiled_modules +# +# Requires: inotifywait (from inotify-tools) or falls back to polling. + +set -euo pipefail + +WATCH_DIR="${1:-./compiled_modules}" +COMPOSE_DIR="$(cd "$(dirname "$0")/.." && pwd)" + +if [ ! -d "$WATCH_DIR" ]; then + echo "Directory $WATCH_DIR does not exist. Create it and add .wasm files first." + exit 1 +fi + +restart_plaid() { + echo "[$(date '+%H:%M:%S')] Change detected — restarting plaid..." + docker compose -C "$COMPOSE_DIR" restart plaid +} + +# Try inotifywait first (efficient, event-based) +if command -v inotifywait &>/dev/null; then + echo "Watching $WATCH_DIR for .wasm changes (inotifywait)..." + echo "Press Ctrl+C to stop." + while inotifywait -q -e close_write,moved_to,create --include '\.wasm$' "$WATCH_DIR"; do + restart_plaid + done +else + # Fallback: poll every 2 seconds + echo "inotifywait not found — falling back to polling (install inotify-tools for efficiency)." + echo "Watching $WATCH_DIR for .wasm changes (polling every 2s)..." + echo "Press Ctrl+C to stop." + + last_hash="" + while true; do + current_hash=$(find "$WATCH_DIR" -name '*.wasm' -exec stat -c '%Y %n' {} + 2>/dev/null | sort | md5sum) + if [ "$current_hash" != "$last_hash" ] && [ -n "$last_hash" ]; then + restart_plaid + fi + last_hash="$current_hash" + sleep 2 + done +fi diff --git a/runtime/plaid/src/apis/general/mod.rs b/runtime/plaid/src/apis/general/mod.rs index c7aff673..5c30565e 100644 --- a/runtime/plaid/src/apis/general/mod.rs +++ b/runtime/plaid/src/apis/general/mod.rs @@ -16,6 +16,7 @@ use super::default_timeout_seconds; #[derive(Deserialize)] pub struct GeneralConfig { /// Configuration for network requests + #[serde(default)] pub network: network::Config, /// The number of seconds until an external API request times out. /// If no value is provided, the result of `default_timeout_seconds()` will be used. diff --git a/runtime/plaid/src/apis/general/network.rs b/runtime/plaid/src/apis/general/network.rs index f1773a80..d145c5ad 100644 --- a/runtime/plaid/src/apis/general/network.rs +++ b/runtime/plaid/src/apis/general/network.rs @@ -33,8 +33,9 @@ struct DynamicWebRequestResponse { cert: Option, } -#[derive(Deserialize)] +#[derive(Default, Deserialize)] pub struct Config { + #[serde(default)] pub web_requests: HashMap, } diff --git a/runtime/plaid/src/loader/mod.rs b/runtime/plaid/src/loader/mod.rs index 168e4251..fd792426 100644 --- a/runtime/plaid/src/loader/mod.rs +++ b/runtime/plaid/src/loader/mod.rs @@ -38,8 +38,10 @@ pub struct LimitedAmount { /// The limit's default value pub default: u64, /// Override values based on log type + #[serde(default)] pub log_type: HashMap, /// Override values based on module names + #[serde(default)] pub module_overrides: HashMap, } @@ -82,8 +84,10 @@ pub struct LimitableAmount { /// The limit's default value default: LimitValue, /// Override values based on log type + #[serde(default)] log_type: HashMap, /// Override values based on module names + #[serde(default)] module_overrides: HashMap, } @@ -105,6 +109,7 @@ pub struct Configuration { /// Where to load modules from pub module_dir: String, /// What the log type of a module should be if it's not the first part of the filename + #[serde(default)] pub log_type_overrides: HashMap, /// How much computation a module is allowed to do #[serde(deserialize_with = "deserialize_limited_amount")] @@ -118,6 +123,7 @@ pub struct Configuration { /// Instead, the values here should be names of secrets whose values are present in /// the secrets file. This makes it possible for to check in your Plaid config without exposing secrets. /// The mapping is `{log_type -> {secret_name -> secret_value}}`. + #[serde(default)] pub secrets: HashMap>, /// Accessory data which is available to all rules (unless overridden by the dedicated override config). /// The mapping is `{key -> value}`` @@ -125,12 +131,15 @@ pub struct Configuration { /// Per-log-type accessory data that is added to universal accessory data for the given log type. In case /// of a name clash, this takes precedence. /// The mapping is `{log_type -> {key -> value}}` + #[serde(default)] pub accessory_data_log_type_overrides: HashMap>, /// Per-rule accessory data that is added to universal accessory data and per-log-type accessory data. In /// case of a name clash, this takes precedence over everything else. /// The mapping is `{rule_file_name -> {key -> value}}` + #[serde(default)] pub accessory_data_file_overrides: HashMap>, /// See persistent_response_size in PlaidModule for an explanation on how to use this + #[serde(default)] pub persistent_response_size: HashMap, /// Modules will be loaded in test_mode meaning they will not be able to make any API calls that /// cause side effects. This does not include: @@ -499,6 +508,16 @@ pub async fn load( type_[0].to_string() }; + // Warn when the inferred log type differs from what the full filename would suggest + let filename_without_ext = filename.trim_end_matches(".wasm"); + if !config.log_type_overrides.contains_key(&filename) && filename_without_ext != type_ { + warn!( + "Module [{filename}] assigned log type [{type_}] (inferred from first segment before '_'). \ + If you expected log type [{filename_without_ext}], add to [loading.log_type_overrides]: \ + \"{filename}\" = \"{filename_without_ext}\"" + ); + } + // Default is the global test mode. Then if the module is in the exemptions specification // we will disable test mode for that module. let test_mode = config.test_mode && !config.test_mode_exemptions.contains(&filename); From 60bb51f79557a29d1727edbb568051b50189c205 Mon Sep 17 00:00:00 2001 From: Harry Anderson Date: Tue, 24 Feb 2026 06:37:57 +0000 Subject: [PATCH 3/8] ci: add smoke test for local-dev docker-compose setup --- .github/workflows/LocalDevCheck.yml | 64 +++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 .github/workflows/LocalDevCheck.yml diff --git a/.github/workflows/LocalDevCheck.yml b/.github/workflows/LocalDevCheck.yml new file mode 100644 index 00000000..c3f8d459 --- /dev/null +++ b/.github/workflows/LocalDevCheck.yml @@ -0,0 +1,64 @@ +name: Local Dev Check + +on: + push: + branches: [main, develop] + pull_request: + branches: "**" + +permissions: + contents: read + +jobs: + local-dev-smoke-test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Build local-dev docker-compose + run: docker compose -f local-dev/docker-compose.yml build + + - name: Start services + run: docker compose -f local-dev/docker-compose.yml up -d + + - name: Wait for healthy + run: | + timeout=120 + elapsed=0 + while [ $elapsed -lt $timeout ]; do + status=$(docker inspect --format='{{.State.Health.Status}}' $(docker compose -f local-dev/docker-compose.yml ps -q plaid) 2>/dev/null || echo "unknown") + if [ "$status" = "healthy" ]; then + echo "Service is healthy after ${elapsed}s" + exit 0 + fi + echo "Waiting for healthy... (${elapsed}s, status: $status)" + sleep 5 + elapsed=$((elapsed + 5)) + done + echo "Timed out waiting for healthy status after ${timeout}s" + exit 1 + + - name: Validate health endpoint + run: | + response=$(curl -sf http://localhost:8080/webhook/health) + if [ "$response" != "ok" ]; then + echo "Expected 'ok', got: $response" + exit 1 + fi + echo "Health check returned: $response" + + - name: Validate hello webhook + run: | + curl -sf -X POST \ + -H "Content-Type: application/json" \ + -d '{"test": true}' \ + http://localhost:8080/webhook/hello + + - name: Dump logs on failure + if: failure() + run: docker compose -f local-dev/docker-compose.yml logs + + - name: Tear down + if: always() + run: docker compose -f local-dev/docker-compose.yml down -v From 63f6520ce0086cbb627169979e28d82e62afac42 Mon Sep 17 00:00:00 2001 From: Harry Anderson Date: Tue, 24 Feb 2026 06:59:27 +0000 Subject: [PATCH 4/8] fix: create empty secrets.toml for CI --- .github/workflows/LocalDevCheck.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/LocalDevCheck.yml b/.github/workflows/LocalDevCheck.yml index c3f8d459..f73df15d 100644 --- a/.github/workflows/LocalDevCheck.yml +++ b/.github/workflows/LocalDevCheck.yml @@ -19,6 +19,9 @@ jobs: - name: Build local-dev docker-compose run: docker compose -f local-dev/docker-compose.yml build + - name: Create empty secrets file + run: touch local-dev/secrets/secrets.toml + - name: Start services run: docker compose -f local-dev/docker-compose.yml up -d From f1fb1ecff531ababaaed444bd222a897a65156b6 Mon Sep 17 00:00:00 2001 From: Harry Anderson <14777088+harry-anderson@users.noreply.github.com> Date: Thu, 26 Feb 2026 09:10:11 +1100 Subject: [PATCH 5/8] feat: add 9 working examples for rule authors (#273) --- local-dev/config/apis.toml | 31 +++- local-dev/config/data.toml | 10 +- local-dev/config/loading.toml | 9 + local-dev/config/webhooks.toml | 52 ++++++ local-dev/rules/Cargo.lock | 102 +++++++++++ local-dev/rules/Cargo.toml | 9 + local-dev/rules/README.md | 179 ++++++++++++++++++++ local-dev/rules/counter/Cargo.toml | 13 ++ local-dev/rules/counter/src/lib.rs | 84 +++++++++ local-dev/rules/cron-heartbeat/Cargo.toml | 13 ++ local-dev/rules/cron-heartbeat/src/lib.rs | 55 ++++++ local-dev/rules/echo-json/Cargo.toml | 13 ++ local-dev/rules/echo-json/src/lib.rs | 78 +++++++++ local-dev/rules/error-handling/Cargo.toml | 14 ++ local-dev/rules/error-handling/src/error.rs | 36 ++++ local-dev/rules/error-handling/src/lib.rs | 118 +++++++++++++ local-dev/rules/http-proxy/Cargo.toml | 13 ++ local-dev/rules/http-proxy/src/lib.rs | 115 +++++++++++++ local-dev/rules/rate-limiter/Cargo.toml | 13 ++ local-dev/rules/rate-limiter/src/lib.rs | 92 ++++++++++ local-dev/rules/request-logger/Cargo.toml | 13 ++ local-dev/rules/request-logger/src/lib.rs | 83 +++++++++ local-dev/rules/todo-api/Cargo.toml | 13 ++ local-dev/rules/todo-api/src/lib.rs | 156 +++++++++++++++++ local-dev/rules/webhook-router/Cargo.toml | 13 ++ local-dev/rules/webhook-router/src/lib.rs | 78 +++++++++ 26 files changed, 1391 insertions(+), 14 deletions(-) create mode 100644 local-dev/rules/README.md create mode 100644 local-dev/rules/counter/Cargo.toml create mode 100644 local-dev/rules/counter/src/lib.rs create mode 100644 local-dev/rules/cron-heartbeat/Cargo.toml create mode 100644 local-dev/rules/cron-heartbeat/src/lib.rs create mode 100644 local-dev/rules/echo-json/Cargo.toml create mode 100644 local-dev/rules/echo-json/src/lib.rs create mode 100644 local-dev/rules/error-handling/Cargo.toml create mode 100644 local-dev/rules/error-handling/src/error.rs create mode 100644 local-dev/rules/error-handling/src/lib.rs create mode 100644 local-dev/rules/http-proxy/Cargo.toml create mode 100644 local-dev/rules/http-proxy/src/lib.rs create mode 100644 local-dev/rules/rate-limiter/Cargo.toml create mode 100644 local-dev/rules/rate-limiter/src/lib.rs create mode 100644 local-dev/rules/request-logger/Cargo.toml create mode 100644 local-dev/rules/request-logger/src/lib.rs create mode 100644 local-dev/rules/todo-api/Cargo.toml create mode 100644 local-dev/rules/todo-api/src/lib.rs create mode 100644 local-dev/rules/webhook-router/Cargo.toml create mode 100644 local-dev/rules/webhook-router/src/lib.rs diff --git a/local-dev/config/apis.toml b/local-dev/config/apis.toml index c7f6f4fb..7cc3dc36 100644 --- a/local-dev/config/apis.toml +++ b/local-dev/config/apis.toml @@ -8,15 +8,28 @@ [apis."general"] -# Example: allow a rule to make HTTP GET requests -# [apis."general".network.web_requests."example_request"] -# verb = "get" -# uri = "https://httpbin.org/get" -# return_body = true -# return_code = true -# return_cert = false -# allowed_rules = ["hello_world.wasm"] -# [apis."general".network.web_requests."example_request".headers] +# ────────────────────────────────────────────────────────────── +# http-proxy example: outbound HTTP requests to httpbin.org +# ────────────────────────────────────────────────────────────── + +[apis."general".network.web_requests."httpbin_get"] +verb = "get" +uri = "https://httpbin.org/get" +return_body = true +return_code = true +return_cert = false +allowed_rules = ["http_proxy.wasm"] +[apis."general".network.web_requests."httpbin_get".headers] + +[apis."general".network.web_requests."httpbin_post"] +verb = "post" +uri = "https://httpbin.org/post" +return_body = true +return_code = true +return_cert = false +allowed_rules = ["http_proxy.wasm"] +[apis."general".network.web_requests."httpbin_post".headers] +Content-Type = "application/json" # Example: Slack integration # [apis."slack"] diff --git a/local-dev/config/data.toml b/local-dev/config/data.toml index e64bdf5d..b717abbd 100644 --- a/local-dev/config/data.toml +++ b/local-dev/config/data.toml @@ -1,7 +1,7 @@ [data] -# Example: cron job that fires every 30 seconds -# [data.interval] -# [data.interval.jobs."my_cron_job"] -# schedule = "0,30 * * * * * *" -# log_type = "my_cron_rule" +# cron-heartbeat example: fires every 30 seconds +[data.interval] +[data.interval.jobs."heartbeat"] +schedule = "0,30 * * * * * *" +log_type = "cron_heartbeat" diff --git a/local-dev/config/loading.toml b/local-dev/config/loading.toml index 2c8f9971..77cfda22 100644 --- a/local-dev/config/loading.toml +++ b/local-dev/config/loading.toml @@ -5,6 +5,15 @@ test_mode = false [loading.log_type_overrides] "hello_world.wasm" = "hello_world" +"echo_json.wasm" = "echo_json" +"counter.wasm" = "counter" +"cron_heartbeat.wasm" = "cron_heartbeat" +"webhook_router.wasm" = "webhook_router" +"rate_limiter.wasm" = "rate_limiter" +"request_logger.wasm" = "request_logger" +"todo_api.wasm" = "todo_api" +"http_proxy.wasm" = "http_proxy" +"error_handling.wasm" = "error_handling" [loading.computation_amount] default = 55_000_000 diff --git a/local-dev/config/webhooks.toml b/local-dev/config/webhooks.toml index 3a81caea..ef1b4176 100644 --- a/local-dev/config/webhooks.toml +++ b/local-dev/config/webhooks.toml @@ -21,3 +21,55 @@ log_type = "health" headers = [] [webhooks."local".webhooks."health".get_mode] response_mode = "static:ok" + +# ────────────────────────────────────────────────────────────── +# Examples +# ────────────────────────────────────────────────────────────── + +# echo-json: Parse JSON and return a response +[webhooks."local".webhooks."echo"] +log_type = "echo_json" +headers = ["Content-Type"] +logbacks_allowed = { Limited = 0 } + +# counter: Increment persistent + in-memory counters +[webhooks."local".webhooks."counter"] +log_type = "counter" +headers = [] +logbacks_allowed = { Limited = 0 } + +# webhook-router: Forward payloads to other rules via logback +[webhooks."local".webhooks."router"] +log_type = "webhook_router" +headers = ["Content-Type"] +logbacks_allowed = "Unlimited" + +# rate-limiter: Per-key rate limiting using cache +[webhooks."local".webhooks."rate-limit"] +log_type = "rate_limiter" +headers = ["Content-Type"] +logbacks_allowed = { Limited = 0 } + +# request-logger: Inspect headers, query params, and metadata +[webhooks."local".webhooks."inspect"] +log_type = "request_logger" +headers = ["Content-Type", "Authorization", "User-Agent", "X-Custom-Header"] +logbacks_allowed = { Limited = 0 } + +# todo-api: CRUD operations backed by persistent storage +[webhooks."local".webhooks."todos"] +log_type = "todo_api" +headers = ["Content-Type"] +logbacks_allowed = { Limited = 0 } + +# http-proxy: Outbound HTTP requests via named request system +[webhooks."local".webhooks."proxy"] +log_type = "http_proxy" +headers = ["Content-Type"] +logbacks_allowed = { Limited = 0 } + +# error-handling: Production error patterns +[webhooks."local".webhooks."errors"] +log_type = "error_handling" +headers = ["Content-Type"] +logbacks_allowed = { Limited = 0 } diff --git a/local-dev/rules/Cargo.lock b/local-dev/rules/Cargo.lock index 8d74067f..d64f1b86 100644 --- a/local-dev/rules/Cargo.lock +++ b/local-dev/rules/Cargo.lock @@ -33,6 +33,43 @@ dependencies = [ "serde", ] +[[package]] +name = "counter" +version = "0.1.0" +dependencies = [ + "plaid_stl", + "serde", + "serde_json", +] + +[[package]] +name = "cron_heartbeat" +version = "0.1.0" +dependencies = [ + "plaid_stl", + "serde", + "serde_json", +] + +[[package]] +name = "echo_json" +version = "0.1.0" +dependencies = [ + "plaid_stl", + "serde", + "serde_json", +] + +[[package]] +name = "error_handling" +version = "0.1.0" +dependencies = [ + "plaid_stl", + "serde", + "serde_json", + "thiserror", +] + [[package]] name = "hello_world" version = "0.1.0" @@ -42,6 +79,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "http_proxy" +version = "0.1.0" +dependencies = [ + "plaid_stl", + "serde", + "serde_json", +] + [[package]] name = "itoa" version = "1.0.17" @@ -99,6 +145,15 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rate_limiter" +version = "0.1.0" +dependencies = [ + "plaid_stl", + "serde", + "serde_json", +] + [[package]] name = "regex" version = "1.12.3" @@ -128,6 +183,15 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" +[[package]] +name = "request_logger" +version = "0.1.0" +dependencies = [ + "plaid_stl", + "serde", + "serde_json", +] + [[package]] name = "serde" version = "1.0.228" @@ -182,12 +246,50 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "todo_api" +version = "0.1.0" +dependencies = [ + "plaid_stl", + "serde", + "serde_json", +] + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "webhook_router" +version = "0.1.0" +dependencies = [ + "plaid_stl", + "serde", + "serde_json", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/local-dev/rules/Cargo.toml b/local-dev/rules/Cargo.toml index c53c8047..dadadf16 100644 --- a/local-dev/rules/Cargo.toml +++ b/local-dev/rules/Cargo.toml @@ -1,6 +1,15 @@ [workspace] members = [ "hello-world", + "echo-json", + "counter", + "cron-heartbeat", + "webhook-router", + "rate-limiter", + "request-logger", + "todo-api", + "http-proxy", + "error-handling", ] resolver = "2" diff --git a/local-dev/rules/README.md b/local-dev/rules/README.md new file mode 100644 index 00000000..466900cc --- /dev/null +++ b/local-dev/rules/README.md @@ -0,0 +1,179 @@ +# Plaid Rule Examples + +Complete, working examples that demonstrate how to write plaid rules. All +examples compile and run with the local-dev Docker setup — no manual +configuration needed. + +## Quick Start + +```sh +cd local-dev +docker compose up --build + +# In another terminal: +curl -s -X POST http://localhost:8080/webhook/echo \ + -H "Content-Type: application/json" \ + -d '{"name": "alice", "age": 30}' +``` + +## Examples + +| Example | Description | Key APIs | Difficulty | +|---------|-------------|----------|------------| +| [hello-world](hello-world/) | Log incoming webhook payloads | `print_debug_string` | Beginner | +| [echo-json](echo-json/) | Parse JSON, return a response | `entrypoint_with_source_and_response!`, serde | Beginner | +| [counter](counter/) | Persistent storage vs in-memory cache | `storage::insert/get`, `cache::insert/get` | Beginner | +| [cron-heartbeat](cron-heartbeat/) | Timer-triggered rule (no webhook) | `Generator::Interval`, `get_time` | Beginner | +| [webhook-router](webhook-router/) | Chain rules together via logback | `log_back`, `LogSource::Logback` | Beginner | +| [rate-limiter](rate-limiter/) | Cache-based per-key rate limiting | `cache::insert/get`, response entrypoint | Intermediate | +| [request-logger](request-logger/) | Inspect HTTP headers and metadata | `get_headers`, `get_query_params` | Intermediate | +| [todo-api](todo-api/) | Full CRUD API backed by storage | `storage::*`, `list_keys` | Intermediate | +| [http-proxy](http-proxy/) | Outbound HTTP requests | `network::make_named_request` | Advanced | +| [error-handling](error-handling/) | Production error patterns | `thiserror`, `set_error_context` | Advanced | + +## Testing Each Example + +### echo-json +```sh +curl -s -X POST http://localhost:8080/webhook/echo \ + -H "Content-Type: application/json" \ + -d '{"name": "alice", "age": 30}' +``` + +### counter +```sh +# Call multiple times to see counts increase: +curl -s -X POST http://localhost:8080/webhook/counter -d 'increment' +``` + +### cron-heartbeat +```sh +# Runs automatically every 30 seconds. Watch the logs: +docker compose logs -f plaid 2>&1 | grep heartbeat +``` + +### webhook-router +```sh +# Route a payload to the hello-world rule: +curl -s -X POST http://localhost:8080/webhook/router \ + -H "Content-Type: application/json" \ + -d '{"target": "hello_world", "body": "routed message!"}' +``` + +### rate-limiter +```sh +# Call 6 times — first 5 succeed, 6th is rate limited: +for i in $(seq 1 6); do + curl -s -X POST http://localhost:8080/webhook/rate-limit \ + -H "Content-Type: application/json" \ + -d '{"key": "user-alice"}' + echo +done +``` + +### request-logger +```sh +curl -s -X POST http://localhost:8080/webhook/inspect \ + -H "Content-Type: application/json" \ + -H "X-Custom-Header: my-value" \ + -H "Authorization: Bearer test-token" \ + -d '{"message": "hello"}' +``` + +### todo-api +```sh +# Create: +curl -s -X POST http://localhost:8080/webhook/todos \ + -H "Content-Type: application/json" \ + -d '{"action": "create", "id": "1", "title": "Buy milk", "done": false}' + +# List: +curl -s -X POST http://localhost:8080/webhook/todos \ + -H "Content-Type: application/json" \ + -d '{"action": "list"}' + +# Delete: +curl -s -X POST http://localhost:8080/webhook/todos \ + -H "Content-Type: application/json" \ + -d '{"action": "delete", "id": "1"}' +``` + +### http-proxy +```sh +# GET request to httpbin.org: +curl -s -X POST http://localhost:8080/webhook/proxy \ + -H "Content-Type: application/json" \ + -d '{"method": "get"}' + +# POST request with body: +curl -s -X POST http://localhost:8080/webhook/proxy \ + -H "Content-Type: application/json" \ + -d '{"method": "post", "body": "{\"hello\": \"world\"}"}' +``` + +### error-handling +```sh +# Valid request: +curl -s -X POST http://localhost:8080/webhook/errors \ + -H "Content-Type: application/json" \ + -d '{"value": 42}' + +# Trigger validation error: +curl -s -X POST http://localhost:8080/webhook/errors \ + -H "Content-Type: application/json" \ + -d '{"value": -1}' +``` + +## Anatomy of a Plaid Rule + +Every plaid rule is a Rust crate that compiles to WebAssembly: + +``` +my-rule/ + Cargo.toml # crate-type = ["cdylib"], depends on plaid_stl + src/lib.rs # rule logic with entrypoint macro +``` + +### 1. Cargo.toml + +```toml +[package] +name = "my_rule" +version = "0.1.0" +edition = "2021" + +[dependencies] +plaid_stl = { path = "../../../runtime/plaid-stl" } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1" + +[lib] +crate-type = ["cdylib"] +``` + +### 2. Entry Point + +Choose the macro that matches your needs: + +| Macro | Signature | Use when | +|-------|-----------|----------| +| `entrypoint!()` | `fn main(data: String) -> Result<(), i32>` | Simple processing, no source info needed | +| `entrypoint_with_source!()` | `fn main(data: String, source: LogSource) -> Result<(), i32>` | Need to know if triggered by webhook, cron, or logback | +| `entrypoint_with_source_and_response!()` | `fn main(data: String, source: LogSource) -> Result, i32>` | Need to return a response to the webhook caller | + +### 3. Config Wiring + +Rules are connected to triggers via TOML config files: + +- **`webhooks.toml`** — maps URL paths to rule log types +- **`loading.toml`** — maps `.wasm` filenames to log types +- **`data.toml`** — defines cron schedules for timer-triggered rules +- **`apis.toml`** — configures outbound HTTP requests, Slack, GitHub, etc. + +## Writing Your Own Rule + +1. Create a new directory under `rules/` with `Cargo.toml` and `src/lib.rs` +2. Add it to the workspace in `rules/Cargo.toml` +3. Add a webhook entry in `config/webhooks.toml` +4. Add a log type override in `config/loading.toml` +5. Run `docker compose up --build` to compile and test diff --git a/local-dev/rules/counter/Cargo.toml b/local-dev/rules/counter/Cargo.toml new file mode 100644 index 00000000..38538a07 --- /dev/null +++ b/local-dev/rules/counter/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "counter" +description = "Demonstrate persistent storage and in-memory cache side-by-side" +version = "0.1.0" +edition = "2021" + +[dependencies] +plaid_stl = { path = "../../../runtime/plaid-stl" } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1" + +[lib] +crate-type = ["cdylib"] diff --git a/local-dev/rules/counter/src/lib.rs b/local-dev/rules/counter/src/lib.rs new file mode 100644 index 00000000..a23eb3fd --- /dev/null +++ b/local-dev/rules/counter/src/lib.rs @@ -0,0 +1,84 @@ +//! # Counter Example +//! +//! Demonstrates the difference between **persistent storage** and +//! **in-memory cache** by maintaining a counter in each. +//! +//! - **Storage** (`plaid::storage`) persists across restarts (backed by Sled). +//! Values are `&[u8]` — you serialize/deserialize yourself. +//! - **Cache** (`plaid::cache`) is in-memory and ephemeral. It resets when plaid +//! restarts. Values are `&str`. +//! +//! Each webhook call increments both counters and returns the current values. +//! Restart plaid to see the cache reset while storage persists. +//! +//! ## Config required +//! ```toml +//! # webhooks.toml +//! [webhooks."local".webhooks."counter"] +//! log_type = "counter" +//! headers = [] +//! logbacks_allowed = { Limited = 0 } +//! ``` +//! +//! ## Try it +//! ```sh +//! # Call multiple times to see counts increase +//! curl -s -X POST http://localhost:8080/webhook/counter -d 'increment' +//! +//! # Restart plaid, then call again — storage count persists, cache resets to 0 +//! ``` + +use plaid_stl::{entrypoint_with_source_and_response, messages::LogSource, plaid}; +use serde::Serialize; + +entrypoint_with_source_and_response!(); + +const STORAGE_KEY: &str = "counter_value"; +const CACHE_KEY: &str = "counter_value"; + +#[derive(Serialize)] +struct CounterState { + storage_count: u64, + cache_count: u64, + note: &'static str, +} + +fn main(_data: String, _source: LogSource) -> Result, i32> { + // --- Persistent storage --- + // Read current value from storage. If the key doesn't exist yet, + // storage::get returns an error — we treat that as 0. + let storage_count = match plaid::storage::get(STORAGE_KEY) { + Ok(bytes) => { + let s = String::from_utf8(bytes).unwrap_or_default(); + s.parse::().unwrap_or(0) + } + Err(_) => 0, + }; + let new_storage_count = storage_count + 1; + + // Write the incremented value back. Storage takes &[u8]. + let value = new_storage_count.to_string(); + let _ = plaid::storage::insert(STORAGE_KEY, value.as_bytes()); + + // --- In-memory cache --- + // Read current value from cache. Same pattern: missing key = 0. + let cache_count = match plaid::cache::get(CACHE_KEY) { + Ok(s) => s.parse::().unwrap_or(0), + Err(_) => 0, + }; + let new_cache_count = cache_count + 1; + + // Write the incremented value back. Cache takes &str. + let _ = plaid::cache::insert(CACHE_KEY, &new_cache_count.to_string()); + + // Return both counts so the caller can see the difference. + let state = CounterState { + storage_count: new_storage_count, + cache_count: new_cache_count, + note: "Restart plaid to see cache reset while storage persists", + }; + + let body = serde_json::to_string_pretty(&state).unwrap(); + plaid::print_debug_string(&format!("[counter] storage={new_storage_count} cache={new_cache_count}")); + Ok(Some(body)) +} diff --git a/local-dev/rules/cron-heartbeat/Cargo.toml b/local-dev/rules/cron-heartbeat/Cargo.toml new file mode 100644 index 00000000..33d2abf3 --- /dev/null +++ b/local-dev/rules/cron-heartbeat/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "cron_heartbeat" +description = "Timer-triggered rule that runs on a cron schedule" +version = "0.1.0" +edition = "2021" + +[dependencies] +plaid_stl = { path = "../../../runtime/plaid-stl" } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1" + +[lib] +crate-type = ["cdylib"] diff --git a/local-dev/rules/cron-heartbeat/src/lib.rs b/local-dev/rules/cron-heartbeat/src/lib.rs new file mode 100644 index 00000000..04a61234 --- /dev/null +++ b/local-dev/rules/cron-heartbeat/src/lib.rs @@ -0,0 +1,55 @@ +//! # Cron Heartbeat Example +//! +//! Demonstrates how to create a rule that runs on a timer (cron schedule) +//! rather than being triggered by a webhook. +//! +//! The rule fires every 30 seconds, logs the current time, and tracks +//! invocation count in storage. +//! +//! ## Key concepts +//! - Cron rules are configured in `data.toml`, not `webhooks.toml` +//! - The `source` will be `LogSource::Generator(Generator::Interval("heartbeat"))` +//! - The `data` payload for cron-triggered rules is empty +//! - You can use `plaid::get_time()` to get the current unix timestamp +//! +//! ## Config required +//! ```toml +//! # data.toml +//! [data.interval] +//! [data.interval.jobs."heartbeat"] +//! schedule = "0,30 * * * * * *" +//! log_type = "cron_heartbeat" +//! ``` +//! +//! ## Try it +//! ```sh +//! # No curl needed — watch the docker logs: +//! docker compose logs -f plaid 2>&1 | grep heartbeat +//! ``` + +use plaid_stl::{entrypoint_with_source, messages::LogSource, plaid}; + +entrypoint_with_source!(); + +const INVOCATION_KEY: &str = "heartbeat_count"; + +fn main(_data: String, source: LogSource) -> Result<(), i32> { + let now = plaid::get_time(); + + // Track how many times we've fired. + let count = match plaid::storage::get(INVOCATION_KEY) { + Ok(bytes) => { + let s = String::from_utf8(bytes).unwrap_or_default(); + s.parse::().unwrap_or(0) + } + Err(_) => 0, + }; + let new_count = count + 1; + let _ = plaid::storage::insert(INVOCATION_KEY, new_count.to_string().as_bytes()); + + plaid::print_debug_string(&format!( + "[cron-heartbeat] tick #{new_count} at unix={now} source={source}" + )); + + Ok(()) +} diff --git a/local-dev/rules/echo-json/Cargo.toml b/local-dev/rules/echo-json/Cargo.toml new file mode 100644 index 00000000..32c8fdf6 --- /dev/null +++ b/local-dev/rules/echo-json/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "echo_json" +description = "Parse a JSON webhook payload and return a structured response" +version = "0.1.0" +edition = "2021" + +[dependencies] +plaid_stl = { path = "../../../runtime/plaid-stl" } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1" + +[lib] +crate-type = ["cdylib"] diff --git a/local-dev/rules/echo-json/src/lib.rs b/local-dev/rules/echo-json/src/lib.rs new file mode 100644 index 00000000..17acbb19 --- /dev/null +++ b/local-dev/rules/echo-json/src/lib.rs @@ -0,0 +1,78 @@ +//! # Echo JSON Example +//! +//! Demonstrates how to parse an incoming JSON webhook payload, extract fields, +//! and return a JSON response to the caller. +//! +//! ## Key concepts +//! - `entrypoint_with_source_and_response!()` — the entry point macro that lets +//! your rule return a response string to the webhook caller +//! - Typed deserialization with `serde::Deserialize` +//! - Returning `Ok(Some(string))` to send a response body back +//! +//! ## Config required +//! ```toml +//! # webhooks.toml +//! [webhooks."local".webhooks."echo"] +//! log_type = "echo_json" +//! headers = ["Content-Type"] +//! logbacks_allowed = { Limited = 0 } +//! ``` +//! +//! ## Try it +//! ```sh +//! curl -s -X POST http://localhost:8080/webhook/echo \ +//! -H "Content-Type: application/json" \ +//! -d '{"name": "alice", "age": 30}' +//! ``` + +use plaid_stl::{entrypoint_with_source_and_response, messages::LogSource, plaid}; +use serde::{Deserialize, Serialize}; + +entrypoint_with_source_and_response!(); + +/// The input we expect from the webhook caller. +#[derive(Deserialize)] +struct Input { + name: String, + #[serde(default)] + age: Option, +} + +/// The response we send back. +#[derive(Serialize)] +struct Output { + greeting: String, + received_at: u32, + source: String, +} + +fn main(data: String, source: LogSource) -> Result, i32> { + plaid::print_debug_string(&format!("[echo-json] received {} bytes", data.len())); + + // Parse the incoming JSON. If it's not valid, return an error response. + let input: Input = match serde_json::from_str(&data) { + Ok(v) => v, + Err(e) => { + let err = format!("{{\"error\": \"invalid JSON: {e}\"}}"); + return Ok(Some(err)); + } + }; + + // Build a response with the parsed fields plus some metadata. + let greeting = match input.age { + Some(age) => format!("Hello, {}! You are {} years old.", input.name, age), + None => format!("Hello, {}!", input.name), + }; + + let response = Output { + greeting, + received_at: plaid::get_time(), + source: source.to_string(), + }; + + // Serialize and return. Returning Ok(Some(string)) sends the string + // as the HTTP response body to the webhook caller. + let body = serde_json::to_string_pretty(&response).unwrap(); + plaid::print_debug_string(&format!("[echo-json] responding with: {body}")); + Ok(Some(body)) +} diff --git a/local-dev/rules/error-handling/Cargo.toml b/local-dev/rules/error-handling/Cargo.toml new file mode 100644 index 00000000..fa454e28 --- /dev/null +++ b/local-dev/rules/error-handling/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "error_handling" +description = "Production error handling patterns with thiserror" +version = "0.1.0" +edition = "2021" + +[dependencies] +plaid_stl = { path = "../../../runtime/plaid-stl" } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1" +thiserror = "2" + +[lib] +crate-type = ["cdylib"] diff --git a/local-dev/rules/error-handling/src/error.rs b/local-dev/rules/error-handling/src/error.rs new file mode 100644 index 00000000..2746694c --- /dev/null +++ b/local-dev/rules/error-handling/src/error.rs @@ -0,0 +1,36 @@ +//! Error types for the error-handling example. +//! +//! This pattern is used in production plaid rules (e.g., `bbqd`): +//! - Define an error enum with `thiserror::Error` +//! - Use `#[from]` for automatic conversion from library errors +//! - Implement `From` manually since it comes from the STL + +use plaid_stl::PlaidFunctionError; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// JSON parsing failed. The `#[from]` attribute means `serde_json::Error` + /// is automatically converted to this variant via the `?` operator. + #[error("failed to parse JSON: {0}")] + ParseError(#[from] serde_json::Error), + + /// Custom validation error. + #[error("validation failed: {0}")] + ValidationFailed(String), + + /// A plaid runtime API call failed. + #[error("plaid API error: {0}")] + PlaidError(String), + + /// Application-level processing error. + #[error("processing failed: {0}")] + ProcessingFailed(String), +} + +/// Manual conversion from PlaidFunctionError since it doesn't implement +/// std::error::Error (it's defined in the WASM-targeted STL). +impl From for Error { + fn from(e: PlaidFunctionError) -> Self { + Error::PlaidError(e.to_string()) + } +} diff --git a/local-dev/rules/error-handling/src/lib.rs b/local-dev/rules/error-handling/src/lib.rs new file mode 100644 index 00000000..8e36d974 --- /dev/null +++ b/local-dev/rules/error-handling/src/lib.rs @@ -0,0 +1,118 @@ +//! # Error Handling Example +//! +//! Demonstrates production-quality error handling patterns for plaid rules, +//! matching the style used in real production rules like `bbqd`. +//! +//! ## Key concepts +//! - `thiserror` crate for ergonomic error enums with `#[from]` derives +//! - `plaid::set_error_context(msg)` — give the runtime more detail about +//! what went wrong (appears in plaid's error logs) +//! - Mapping `PlaidFunctionError` to your own error type +//! - Separating error types into their own module (`error.rs`) +//! +//! ## Config required +//! ```toml +//! # webhooks.toml +//! [webhooks."local".webhooks."errors"] +//! log_type = "error_handling" +//! headers = ["Content-Type"] +//! logbacks_allowed = { Limited = 0 } +//! ``` +//! +//! ## Try it +//! ```sh +//! # Valid request: +//! curl -s -X POST http://localhost:8080/webhook/errors \ +//! -H "Content-Type: application/json" \ +//! -d '{"value": 42}' +//! +//! # Trigger a parse error: +//! curl -s -X POST http://localhost:8080/webhook/errors \ +//! -d 'not json' +//! +//! # Trigger a validation error: +//! curl -s -X POST http://localhost:8080/webhook/errors \ +//! -H "Content-Type: application/json" \ +//! -d '{"value": -1}' +//! +//! # Trigger a storage error (value too large to display): +//! curl -s -X POST http://localhost:8080/webhook/errors \ +//! -H "Content-Type: application/json" \ +//! -d '{"value": 999}' +//! ``` + +mod error; + +use error::Error; +use plaid_stl::{entrypoint_with_source_and_response, messages::LogSource, plaid}; +use serde::{Deserialize, Serialize}; + +entrypoint_with_source_and_response!(); + +#[derive(Deserialize)] +struct Input { + value: i64, +} + +#[derive(Serialize)] +struct Output { + result: String, + stored: bool, +} + +fn process(data: &str) -> Result { + // Step 1: Parse the input. serde_json::Error automatically converts + // to our Error type via #[from]. + let input: Input = serde_json::from_str(data)?; + + // Step 2: Validate the input. This is a custom error variant. + if input.value < 0 { + return Err(Error::ValidationFailed(format!( + "value must be non-negative, got {}", + input.value + ))); + } + + // Step 3: Interact with plaid APIs. PlaidFunctionError converts + // to our Error type via the manual From impl in error.rs. + let result = format!("processed_{}", input.value); + + // Step 4: Try to store the result. Demonstrate error context. + let stored = match plaid::storage::insert("last_result", result.as_bytes()) { + Ok(_) => true, + Err(e) => { + // set_error_context gives the runtime more detail about the error. + // This appears in plaid's logs alongside the error code. + plaid::set_error_context(&format!("storage insert failed: {e}")); + false + } + }; + + // Step 5: Simulate an error for large values (demonstrates error flow). + if input.value > 100 { + return Err(Error::ProcessingFailed( + "value exceeds maximum threshold of 100".to_string(), + )); + } + + Ok(Output { result, stored }) +} + +fn main(data: String, _source: LogSource) -> Result, i32> { + match process(&data) { + Ok(output) => { + let body = serde_json::to_string_pretty(&output).unwrap(); + Ok(Some(body)) + } + Err(e) => { + // Log the error for debugging. In production, this would go to + // your monitoring system. + plaid::print_debug_string(&format!("[error-handling] error: {e}")); + plaid::set_error_context(&e.to_string()); + + // Return an error response to the caller with details. + let body = format!("{{\"error\": \"{e}\"}}"); + Ok(Some(body)) + } + } +} diff --git a/local-dev/rules/http-proxy/Cargo.toml b/local-dev/rules/http-proxy/Cargo.toml new file mode 100644 index 00000000..ae1734e5 --- /dev/null +++ b/local-dev/rules/http-proxy/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "http_proxy" +description = "Make outbound HTTP requests using the named request system" +version = "0.1.0" +edition = "2021" + +[dependencies] +plaid_stl = { path = "../../../runtime/plaid-stl" } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1" + +[lib] +crate-type = ["cdylib"] diff --git a/local-dev/rules/http-proxy/src/lib.rs b/local-dev/rules/http-proxy/src/lib.rs new file mode 100644 index 00000000..d50ec078 --- /dev/null +++ b/local-dev/rules/http-proxy/src/lib.rs @@ -0,0 +1,115 @@ +//! # HTTP Proxy Example +//! +//! Demonstrates how to make **outbound HTTP requests** from a plaid rule using +//! the named request system. Named requests are pre-configured in `apis.toml` +//! with a fixed URL, verb, and headers. The rule references them by name. +//! +//! This example uses [httpbin.org](https://httpbin.org) as a safe, public +//! endpoint that echoes back request details. +//! +//! ## Key concepts +//! - Named requests are defined in `apis.toml` under `[apis."general".network.web_requests]` +//! - Each named request specifies: verb, uri, allowed_rules, return_body/code +//! - `network::make_named_request(name, body, variables)` executes the request +//! - The response includes an optional HTTP status code and response body +//! - `variables` are substituted into the URI template (e.g., `{id}` in the URL) +//! +//! ## Config required +//! ```toml +//! # apis.toml +//! [apis."general".network.web_requests."httpbin_get"] +//! verb = "get" +//! uri = "https://httpbin.org/get" +//! return_body = true +//! return_code = true +//! return_cert = false +//! allowed_rules = ["http_proxy.wasm"] +//! [apis."general".network.web_requests."httpbin_get".headers] +//! +//! [apis."general".network.web_requests."httpbin_post"] +//! verb = "post" +//! uri = "https://httpbin.org/post" +//! return_body = true +//! return_code = true +//! return_cert = false +//! allowed_rules = ["http_proxy.wasm"] +//! [apis."general".network.web_requests."httpbin_post".headers] +//! Content-Type = "application/json" +//! ``` +//! +//! ## Try it +//! ```sh +//! # GET request: +//! curl -s -X POST http://localhost:8080/webhook/proxy \ +//! -H "Content-Type: application/json" \ +//! -d '{"method": "get"}' +//! +//! # POST request with body: +//! curl -s -X POST http://localhost:8080/webhook/proxy \ +//! -H "Content-Type: application/json" \ +//! -d '{"method": "post", "body": "{\"hello\": \"world\"}"}' +//! ``` + +use std::collections::HashMap; + +use plaid_stl::{entrypoint_with_source_and_response, messages::LogSource, network, plaid}; +use serde::{Deserialize, Serialize}; + +entrypoint_with_source_and_response!(); + +#[derive(Deserialize)] +struct ProxyRequest { + method: String, + #[serde(default)] + body: Option, +} + +#[derive(Serialize)] +struct ProxyResponse { + status_code: Option, + body: Option, +} + +fn main(data: String, _source: LogSource) -> Result, i32> { + let request: ProxyRequest = match serde_json::from_str(&data) { + Ok(r) => r, + Err(e) => return Ok(Some(format!("{{\"error\": \"invalid JSON: {e}\"}}"))), + }; + + // Choose the named request based on the method field. + let request_name = match request.method.as_str() { + "get" => "httpbin_get", + "post" => "httpbin_post", + other => { + return Ok(Some(format!("{{\"error\": \"unsupported method: {other}\"}}"))); + } + }; + + let body = request.body.unwrap_or_default(); + let variables: HashMap = HashMap::new(); + + plaid::print_debug_string(&format!( + "[http-proxy] making named request '{request_name}' with {} byte body", + body.len() + )); + + // make_named_request returns a WebRequestResponse with optional code and data. + let response = network::make_named_request(request_name, &body, variables).map_err(|e| { + plaid::print_debug_string(&format!("[http-proxy] request failed: {e}")); + 1 + })?; + + plaid::print_debug_string(&format!( + "[http-proxy] response: code={:?} body_len={:?}", + response.code, + response.data.as_ref().map(|d| d.len()) + )); + + let proxy_response = ProxyResponse { + status_code: response.code, + body: response.data, + }; + + let result = serde_json::to_string_pretty(&proxy_response).unwrap(); + Ok(Some(result)) +} diff --git a/local-dev/rules/rate-limiter/Cargo.toml b/local-dev/rules/rate-limiter/Cargo.toml new file mode 100644 index 00000000..b6225ee3 --- /dev/null +++ b/local-dev/rules/rate-limiter/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "rate_limiter" +description = "Cache-based rate limiting pattern" +version = "0.1.0" +edition = "2021" + +[dependencies] +plaid_stl = { path = "../../../runtime/plaid-stl" } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1" + +[lib] +crate-type = ["cdylib"] diff --git a/local-dev/rules/rate-limiter/src/lib.rs b/local-dev/rules/rate-limiter/src/lib.rs new file mode 100644 index 00000000..6c7dbdd4 --- /dev/null +++ b/local-dev/rules/rate-limiter/src/lib.rs @@ -0,0 +1,92 @@ +//! # Rate Limiter Example +//! +//! Demonstrates a practical pattern: using the in-memory **cache** to implement +//! per-key rate limiting. Each key gets a fixed number of requests; once +//! exceeded, the rule returns an error response. +//! +//! ## Key concepts +//! - Cache as a lightweight counter (no TTL in plaid cache, but values reset +//! on restart — for true TTL, use storage with timestamps) +//! - Returning error responses to webhook callers via the response entrypoint +//! - Pattern: read-increment-write with cache +//! +//! ## Config required +//! ```toml +//! # webhooks.toml +//! [webhooks."local".webhooks."rate-limit"] +//! log_type = "rate_limiter" +//! headers = ["Content-Type"] +//! logbacks_allowed = { Limited = 0 } +//! ``` +//! +//! ## Try it +//! ```sh +//! # Call 6 times — first 5 succeed, 6th is rate limited: +//! for i in $(seq 1 6); do +//! echo "--- Request $i ---" +//! curl -s -X POST http://localhost:8080/webhook/rate-limit \ +//! -H "Content-Type: application/json" \ +//! -d '{"key": "user-alice"}' +//! echo +//! done +//! ``` + +use plaid_stl::{entrypoint_with_source_and_response, messages::LogSource, plaid}; +use serde::{Deserialize, Serialize}; + +entrypoint_with_source_and_response!(); + +/// Maximum requests allowed per key before rate limiting kicks in. +const MAX_REQUESTS: u64 = 5; + +#[derive(Deserialize)] +struct Request { + key: String, +} + +#[derive(Serialize)] +struct RateLimitResponse { + key: String, + request_count: u64, + limit: u64, + allowed: bool, +} + +fn main(data: String, _source: LogSource) -> Result, i32> { + let request: Request = match serde_json::from_str(&data) { + Ok(r) => r, + Err(e) => { + let err = format!("{{\"error\": \"invalid JSON: {e}\"}}"); + return Ok(Some(err)); + } + }; + + // Read the current count for this key from cache. + let count = match plaid::cache::get(&request.key) { + Ok(s) => s.parse::().unwrap_or(0), + Err(_) => 0, + }; + + let new_count = count + 1; + let allowed = new_count <= MAX_REQUESTS; + + // Always update the counter, even if rate limited. + let _ = plaid::cache::insert(&request.key, &new_count.to_string()); + + let response = RateLimitResponse { + key: request.key, + request_count: new_count, + limit: MAX_REQUESTS, + allowed, + }; + + if !allowed { + plaid::print_debug_string(&format!( + "[rate-limiter] DENIED: {} has {} requests (limit {})", + response.key, new_count, MAX_REQUESTS + )); + } + + let body = serde_json::to_string_pretty(&response).unwrap(); + Ok(Some(body)) +} diff --git a/local-dev/rules/request-logger/Cargo.toml b/local-dev/rules/request-logger/Cargo.toml new file mode 100644 index 00000000..990e2651 --- /dev/null +++ b/local-dev/rules/request-logger/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "request_logger" +description = "Access HTTP headers, query parameters, and accessory data" +version = "0.1.0" +edition = "2021" + +[dependencies] +plaid_stl = { path = "../../../runtime/plaid-stl" } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1" + +[lib] +crate-type = ["cdylib"] diff --git a/local-dev/rules/request-logger/src/lib.rs b/local-dev/rules/request-logger/src/lib.rs new file mode 100644 index 00000000..0eb38877 --- /dev/null +++ b/local-dev/rules/request-logger/src/lib.rs @@ -0,0 +1,83 @@ +//! # Request Logger Example +//! +//! Demonstrates how to access all the contextual data available to a rule: +//! HTTP headers, query parameters, accessory data from config, and secrets. +//! +//! ## Key concepts +//! - `plaid::get_headers(name)` — retrieve a forwarded HTTP header by name +//! - `plaid::get_query_params(name)` — retrieve a URL query parameter by name +//! - `plaid::get_accessory_data(name)` — retrieve static config data +//! - `plaid::get_secrets(name)` — retrieve secrets from secrets.toml +//! - Headers must be listed in the webhook config to be forwarded +//! +//! ## Config required +//! ```toml +//! # webhooks.toml +//! [webhooks."local".webhooks."inspect"] +//! log_type = "request_logger" +//! headers = ["Content-Type", "Authorization", "User-Agent", "X-Custom-Header"] +//! logbacks_allowed = { Limited = 0 } +//! ``` +//! +//! ## Try it +//! ```sh +//! # POST with custom headers: +//! curl -s -X POST http://localhost:8080/webhook/inspect \ +//! -H "Content-Type: application/json" \ +//! -H "X-Custom-Header: my-value" \ +//! -H "Authorization: Bearer test-token" \ +//! -d '{"message": "hello"}' +//! ``` + +use plaid_stl::{entrypoint_with_source_and_response, messages::LogSource, plaid}; +use serde::Serialize; + +entrypoint_with_source_and_response!(); + +/// The headers we attempt to read. These must also be listed in the +/// webhook config's `headers` array to be forwarded by plaid. +const HEADER_NAMES: &[&str] = &[ + "Content-Type", + "Authorization", + "User-Agent", + "X-Custom-Header", +]; + +#[derive(Serialize)] +struct InspectionResult { + source: String, + body_length: usize, + headers: Vec, +} + +#[derive(Serialize)] +struct HeaderEntry { + name: String, + value: Option, +} + +fn main(data: String, source: LogSource) -> Result, i32> { + plaid::print_debug_string(&format!("[request-logger] source={source}")); + plaid::print_debug_string(&format!("[request-logger] body ({} bytes): {data}", data.len())); + + // Read each configured header. get_headers returns Err if the header + // wasn't forwarded or wasn't present in the request. + let mut headers = Vec::new(); + for &name in HEADER_NAMES { + let value = plaid::get_headers(name).ok(); + plaid::print_debug_string(&format!("[request-logger] header {name}: {value:?}")); + headers.push(HeaderEntry { + name: name.to_string(), + value, + }); + } + + let result = InspectionResult { + source: source.to_string(), + body_length: data.len(), + headers, + }; + + let body = serde_json::to_string_pretty(&result).unwrap(); + Ok(Some(body)) +} diff --git a/local-dev/rules/todo-api/Cargo.toml b/local-dev/rules/todo-api/Cargo.toml new file mode 100644 index 00000000..f16e816c --- /dev/null +++ b/local-dev/rules/todo-api/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "todo_api" +description = "Full CRUD REST-like API backed by plaid storage" +version = "0.1.0" +edition = "2021" + +[dependencies] +plaid_stl = { path = "../../../runtime/plaid-stl" } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1" + +[lib] +crate-type = ["cdylib"] diff --git a/local-dev/rules/todo-api/src/lib.rs b/local-dev/rules/todo-api/src/lib.rs new file mode 100644 index 00000000..fac20317 --- /dev/null +++ b/local-dev/rules/todo-api/src/lib.rs @@ -0,0 +1,156 @@ +//! # Todo API Example +//! +//! A complete CRUD API backed by plaid's persistent storage. Demonstrates how +//! to build a stateful "mini web service" inside a single plaid rule. +//! +//! ## Key concepts +//! - `plaid::storage::insert(key, value)` — create or update a record +//! - `plaid::storage::get(key)` — read a record +//! - `plaid::storage::delete(key)` — remove a record +//! - `plaid::storage::list_keys(prefix)` — list all keys with a given prefix +//! - Using a key prefix (`todo:`) to namespace your data +//! +//! ## Config required +//! ```toml +//! # webhooks.toml +//! [webhooks."local".webhooks."todos"] +//! log_type = "todo_api" +//! headers = ["Content-Type"] +//! logbacks_allowed = { Limited = 0 } +//! ``` +//! +//! ## Try it +//! ```sh +//! # Create a todo: +//! curl -s -X POST http://localhost:8080/webhook/todos \ +//! -H "Content-Type: application/json" \ +//! -d '{"action": "create", "id": "1", "title": "Buy milk", "done": false}' +//! +//! # List all todos: +//! curl -s -X POST http://localhost:8080/webhook/todos \ +//! -H "Content-Type: application/json" \ +//! -d '{"action": "list"}' +//! +//! # Get a specific todo: +//! curl -s -X POST http://localhost:8080/webhook/todos \ +//! -H "Content-Type: application/json" \ +//! -d '{"action": "get", "id": "1"}' +//! +//! # Update a todo: +//! curl -s -X POST http://localhost:8080/webhook/todos \ +//! -H "Content-Type: application/json" \ +//! -d '{"action": "update", "id": "1", "title": "Buy oat milk", "done": true}' +//! +//! # Delete a todo: +//! curl -s -X POST http://localhost:8080/webhook/todos \ +//! -H "Content-Type: application/json" \ +//! -d '{"action": "delete", "id": "1"}' +//! ``` + +use plaid_stl::{entrypoint_with_source_and_response, messages::LogSource, plaid}; +use serde::{Deserialize, Serialize}; + +entrypoint_with_source_and_response!(); + +/// All todo keys are prefixed with this string so they don't collide +/// with keys from other rules sharing the same storage. +const KEY_PREFIX: &str = "todo:"; + +#[derive(Deserialize)] +struct Request { + action: String, + #[serde(default)] + id: Option, + #[serde(default)] + title: Option, + #[serde(default)] + done: Option, +} + +#[derive(Serialize, Deserialize)] +struct Todo { + id: String, + title: String, + done: bool, +} + +#[derive(Serialize)] +struct ListResponse { + count: usize, + todos: Vec, +} + +fn storage_key(id: &str) -> String { + format!("{KEY_PREFIX}{id}") +} + +fn main(data: String, _source: LogSource) -> Result, i32> { + let req: Request = match serde_json::from_str(&data) { + Ok(r) => r, + Err(e) => return Ok(Some(format!("{{\"error\": \"invalid JSON: {e}\"}}"))), + }; + + let result = match req.action.as_str() { + "create" | "update" => { + let id = match &req.id { + Some(id) => id.clone(), + None => return Ok(Some("{\"error\": \"missing id\"}".to_string())), + }; + let todo = Todo { + id: id.clone(), + title: req.title.unwrap_or_else(|| "untitled".to_string()), + done: req.done.unwrap_or(false), + }; + let json = serde_json::to_vec(&todo).unwrap(); + let _ = plaid::storage::insert(&storage_key(&id), &json); + plaid::print_debug_string(&format!("[todo-api] {} todo '{}'", req.action, id)); + serde_json::to_string_pretty(&todo).unwrap() + } + + "get" => { + let id = match &req.id { + Some(id) => id.clone(), + None => return Ok(Some("{\"error\": \"missing id\"}".to_string())), + }; + match plaid::storage::get(&storage_key(&id)) { + Ok(bytes) => { + // Return the raw stored JSON. + String::from_utf8(bytes).unwrap_or_else(|_| "{\"error\": \"corrupt data\"}".to_string()) + } + Err(_) => format!("{{\"error\": \"todo '{id}' not found\"}}"), + } + } + + "delete" => { + let id = match &req.id { + Some(id) => id.clone(), + None => return Ok(Some("{\"error\": \"missing id\"}".to_string())), + }; + let _ = plaid::storage::delete(&storage_key(&id)); + plaid::print_debug_string(&format!("[todo-api] deleted todo '{id}'")); + format!("{{\"deleted\": \"{id}\"}}") + } + + "list" => { + // list_keys returns all keys matching the given prefix. + let keys = plaid::storage::list_keys(Some(KEY_PREFIX)).unwrap_or_default(); + let mut todos = Vec::new(); + for key in &keys { + if let Ok(bytes) = plaid::storage::get(key) { + if let Ok(todo) = serde_json::from_slice::(&bytes) { + todos.push(todo); + } + } + } + let response = ListResponse { + count: todos.len(), + todos, + }; + serde_json::to_string_pretty(&response).unwrap() + } + + other => format!("{{\"error\": \"unknown action: {other}\"}}"), + }; + + Ok(Some(result)) +} diff --git a/local-dev/rules/webhook-router/Cargo.toml b/local-dev/rules/webhook-router/Cargo.toml new file mode 100644 index 00000000..f37d2b69 --- /dev/null +++ b/local-dev/rules/webhook-router/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "webhook_router" +description = "Chain rules together using the logback system" +version = "0.1.0" +edition = "2021" + +[dependencies] +plaid_stl = { path = "../../../runtime/plaid-stl" } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1" + +[lib] +crate-type = ["cdylib"] diff --git a/local-dev/rules/webhook-router/src/lib.rs b/local-dev/rules/webhook-router/src/lib.rs new file mode 100644 index 00000000..9fa1f236 --- /dev/null +++ b/local-dev/rules/webhook-router/src/lib.rs @@ -0,0 +1,78 @@ +//! # Webhook Router Example +//! +//! Demonstrates how to chain rules together using the **logback** system. +//! This rule receives a JSON payload with a `target` field and forwards the +//! `body` to the appropriate downstream rule via `plaid::log_back()`. +//! +//! ## Key concepts +//! - `plaid::log_back(log_type, data, delay)` sends data to another rule +//! - The `log_type` must match a rule's configured log type +//! - `delay` is in seconds (0 = immediate) +//! - The webhook config must set `logbacks_allowed = "Unlimited"` (or a +//! sufficient `Limited` value) for the rule to be allowed to logback +//! - Downstream rules see `LogSource::Logback(...)` as their source +//! +//! ## Config required +//! ```toml +//! # webhooks.toml +//! [webhooks."local".webhooks."router"] +//! log_type = "webhook_router" +//! headers = ["Content-Type"] +//! logbacks_allowed = "Unlimited" +//! ``` +//! +//! ## Try it +//! ```sh +//! # Route to the hello-world rule: +//! curl -s -X POST http://localhost:8080/webhook/router \ +//! -H "Content-Type: application/json" \ +//! -d '{"target": "hello_world", "body": "routed from webhook-router!"}' +//! +//! # Route to the echo-json rule: +//! curl -s -X POST http://localhost:8080/webhook/router \ +//! -H "Content-Type: application/json" \ +//! -d '{"target": "echo_json", "body": "{\"name\": \"bob\"}"}' +//! ``` + +use plaid_stl::{entrypoint_with_source, messages::LogSource, plaid}; +use serde::Deserialize; + +entrypoint_with_source!(); + +#[derive(Deserialize)] +struct RouterRequest { + /// The log_type of the downstream rule to forward to. + target: String, + /// The payload to send to the downstream rule. + body: String, + /// Optional delay in seconds before the downstream rule fires. + #[serde(default)] + delay: u32, +} + +fn main(data: String, source: LogSource) -> Result<(), i32> { + plaid::print_debug_string(&format!("[webhook-router] received from {source}")); + + let request: RouterRequest = serde_json::from_str(&data).map_err(|e| { + plaid::print_debug_string(&format!("[webhook-router] invalid JSON: {e}")); + 1 + })?; + + plaid::print_debug_string(&format!( + "[webhook-router] routing {} bytes to '{}' (delay={}s)", + request.body.len(), + request.target, + request.delay, + )); + + // log_back sends the body to whatever rule is listening on the given log_type. + // The delay parameter specifies how many seconds to wait before delivering. + plaid::log_back(&request.target, request.body.as_bytes(), request.delay) + .map_err(|_| { + plaid::print_debug_string("[webhook-router] logback failed — check logbacks_allowed config"); + 1 + })?; + + plaid::print_debug_string("[webhook-router] logback sent successfully"); + Ok(()) +} From bca3d6e3114102c0c11478bd068ec38600baa8e1 Mon Sep 17 00:00:00 2001 From: Harry Anderson Date: Thu, 26 Feb 2026 19:36:03 +0000 Subject: [PATCH 6/8] fix: make rust version a build arg, default to 1.93 --- local-dev/Dockerfile | 6 ++++-- runtime/plaid/src/apis/slack/api.rs | 13 ++++++------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/local-dev/Dockerfile b/local-dev/Dockerfile index 578ad3a0..9f4dae93 100644 --- a/local-dev/Dockerfile +++ b/local-dev/Dockerfile @@ -5,7 +5,8 @@ # Stage 3: Minimal runtime image # ── Stage 1: Build plaid runtime ───────────────────────────────────────────── -FROM rust:1.88.0-slim-bookworm AS runtime-builder +ARG RUST_VERSION=1.93.0 +FROM rust:${RUST_VERSION}-slim-bookworm AS runtime-builder RUN apt-get update && apt-get install -y \ build-essential \ @@ -27,7 +28,8 @@ RUN cargo build \ --features=cranelift,sled # ── Stage 2: Build WASM modules ───────────────────────────────────────────── -FROM rust:1.88.0-slim-bookworm AS module-builder +ARG RUST_VERSION=1.93.0 +FROM rust:${RUST_VERSION}-slim-bookworm AS module-builder RUN rustup target add wasm32-unknown-unknown diff --git a/runtime/plaid/src/apis/slack/api.rs b/runtime/plaid/src/apis/slack/api.rs index 32522dd3..08f300fb 100644 --- a/runtime/plaid/src/apis/slack/api.rs +++ b/runtime/plaid/src/apis/slack/api.rs @@ -1,9 +1,8 @@ use std::sync::Arc; use plaid_stl::slack::{ - CreateChannel, CreateChannelResponse, GetDndInfo, GetDndInfoResponse, GetIdFromEmail, - GetPresence, GetPresenceResponse, InviteToChannel, PostMessage, UserInfo, UserInfoResponse, - ViewOpen, + CreateChannel, GetDndInfo, GetDndInfoResponse, GetIdFromEmail, GetPresence, + GetPresenceResponse, InviteToChannel, PostMessage, UserInfo, UserInfoResponse, ViewOpen, }; use reqwest::{Client, RequestBuilder}; @@ -291,11 +290,11 @@ impl Slack { .await { Ok((200, response)) => { - let cc_response: CreateChannelResponse = - serde_json::from_str(&response).map_err(|e| { - ApiError::SlackError(SlackError::UnexpectedPayload(e.to_string())) + let slack_response: GenericSlackResponse = serde_json::from_str(&response) + .map_err(|_| { + ApiError::SlackError(SlackError::UnexpectedPayload(response.clone())) })?; - if !cc_response.ok { + if !slack_response.ok { return Err(ApiError::SlackError(SlackError::UnexpectedPayload( response, ))); From 32f12b1bfeba71db44239d6d4a4eb9c3dea963bf Mon Sep 17 00:00:00 2001 From: Harry Anderson Date: Thu, 26 Feb 2026 19:37:38 +0000 Subject: [PATCH 7/8] chore: remove unrelated slack api changes --- runtime/plaid/src/apis/slack/api.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/runtime/plaid/src/apis/slack/api.rs b/runtime/plaid/src/apis/slack/api.rs index 08f300fb..32522dd3 100644 --- a/runtime/plaid/src/apis/slack/api.rs +++ b/runtime/plaid/src/apis/slack/api.rs @@ -1,8 +1,9 @@ use std::sync::Arc; use plaid_stl::slack::{ - CreateChannel, GetDndInfo, GetDndInfoResponse, GetIdFromEmail, GetPresence, - GetPresenceResponse, InviteToChannel, PostMessage, UserInfo, UserInfoResponse, ViewOpen, + CreateChannel, CreateChannelResponse, GetDndInfo, GetDndInfoResponse, GetIdFromEmail, + GetPresence, GetPresenceResponse, InviteToChannel, PostMessage, UserInfo, UserInfoResponse, + ViewOpen, }; use reqwest::{Client, RequestBuilder}; @@ -290,11 +291,11 @@ impl Slack { .await { Ok((200, response)) => { - let slack_response: GenericSlackResponse = serde_json::from_str(&response) - .map_err(|_| { - ApiError::SlackError(SlackError::UnexpectedPayload(response.clone())) + let cc_response: CreateChannelResponse = + serde_json::from_str(&response).map_err(|e| { + ApiError::SlackError(SlackError::UnexpectedPayload(e.to_string())) })?; - if !slack_response.ok { + if !cc_response.ok { return Err(ApiError::SlackError(SlackError::UnexpectedPayload( response, ))); From 95ad477b1723eca31db84e9e5cb6a11dd4c93331 Mon Sep 17 00:00:00 2001 From: Harry Anderson Date: Fri, 27 Feb 2026 02:27:08 +0000 Subject: [PATCH 8/8] fix: revert rust default to 1.88, keep as build arg --- local-dev/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/local-dev/Dockerfile b/local-dev/Dockerfile index 9f4dae93..f494f978 100644 --- a/local-dev/Dockerfile +++ b/local-dev/Dockerfile @@ -5,7 +5,7 @@ # Stage 3: Minimal runtime image # ── Stage 1: Build plaid runtime ───────────────────────────────────────────── -ARG RUST_VERSION=1.93.0 +ARG RUST_VERSION=1.88.0 FROM rust:${RUST_VERSION}-slim-bookworm AS runtime-builder RUN apt-get update && apt-get install -y \ @@ -28,7 +28,7 @@ RUN cargo build \ --features=cranelift,sled # ── Stage 2: Build WASM modules ───────────────────────────────────────────── -ARG RUST_VERSION=1.93.0 +ARG RUST_VERSION=1.88.0 FROM rust:${RUST_VERSION}-slim-bookworm AS module-builder RUN rustup target add wasm32-unknown-unknown