From 10b5f655d52637cf14863edecb403bf52cb0e8f9 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Wed, 3 Jun 2026 20:23:18 +0200 Subject: [PATCH] feat(v1.1.4): outbound HTTP SDK + cron triggers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HTTP (`http::*`): - `HttpService` trait (picloud-shared) + reqwest-backed `HttpServiceImpl` (manager-core), wired into the `Services` bundle. - SSRF deny-list applied to the resolved IP via a custom reqwest `dns_resolver` (covers every redirect hop + defeats DNS rebinding) plus a literal-IP check at URL-parse time. Scheme/port restrictions, request + response body caps (stream-with-cap), layered timeout. Error reason is a CIDR category, never the IP. `PICLOUD_HTTP_ALLOW_PRIVATE` dev override (logs a startup warning). - Rhai bridge with three-arg split `verb(url, body, opts)` (resolves the brief's body-vs-opts contradiction; unknown opt keys throw). Body dispatch by type; response `#{status,headers,body,body_raw}` with JSON auto-parse; non-2xx does not throw. - `Capability::AppHttpRequest` → existing `script:write` scope (no new Scope variant). `SdkCallCx` gains `script_id` (attribution + User-Agent). Cron triggers (4th trigger kind): - Migration 0017 widens the kind/source_kind CHECKs and adds `cron_trigger_details`. `cron`/`chrono-tz` parse + validate 6-field schedules and IANA timezones. - `spawn_cron_scheduler` polls due triggers and enqueues to the universal outbox; the dispatcher delivers them (one-line match-arm extension). Catch-up fires exactly once per trigger per tick, not once per missed window. `ctx.event.cron` for handlers. - `POST /api/v1/admin/apps/{id}/triggers/cron` reuses the v1.1.3 cross-app + kind!=module target check. - Dashboard: admin-gated Triggers tab (cron create form + list). Follow-ups: redact module backend errors at the resolver boundary (log original at error level); pin `rhai = "=1.24"`; CHANGELOG incl. retroactive v1.1.3 cross-app-trigger security note. Version bumps: workspace 1.1.4, SDK 1.5, dashboard 0.10.0. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 102 +++ Cargo.lock | 125 ++- Cargo.toml | 10 +- crates/executor-core/Cargo.toml | 7 + crates/executor-core/src/engine.rs | 18 + crates/executor-core/src/module_resolver.rs | 14 +- crates/executor-core/src/sdk/http.rs | 391 +++++++++ crates/executor-core/src/sdk/mod.rs | 4 +- .../tests/module_redaction_logging.rs | 127 +++ crates/executor-core/tests/modules.rs | 23 +- crates/executor-core/tests/sdk_docs.rs | 5 +- crates/executor-core/tests/sdk_http.rs | 334 ++++++++ crates/executor-core/tests/sdk_kv.rs | 4 +- crates/manager-core/Cargo.toml | 3 + .../migrations/0017_cron_triggers.sql | 43 + crates/manager-core/src/authz.rs | 15 +- crates/manager-core/src/cron_scheduler.rs | 297 +++++++ crates/manager-core/src/dead_letters_api.rs | 3 + crates/manager-core/src/dispatcher.rs | 5 +- crates/manager-core/src/docs_service.rs | 5 +- crates/manager-core/src/http_service.rs | 793 ++++++++++++++++++ crates/manager-core/src/kv_service.rs | 5 +- crates/manager-core/src/lib.rs | 5 + crates/manager-core/src/outbox_repo.rs | 4 + crates/manager-core/src/ssrf.rs | 457 ++++++++++ crates/manager-core/src/trigger_config.rs | 11 + crates/manager-core/src/trigger_repo.rs | 123 +++ crates/manager-core/src/triggers_api.rs | 264 +++++- crates/manager-core/tests/expected_schema.txt | 217 +++++ crates/picloud/src/lib.rs | 36 +- crates/shared/src/http.rs | 137 +++ crates/shared/src/lib.rs | 2 + crates/shared/src/sdk_cx.rs | 7 +- crates/shared/src/services.rs | 14 +- crates/shared/src/trigger_event.rs | 13 + crates/shared/src/version.rs | 8 +- dashboard/package.json | 2 +- dashboard/src/lib/api.ts | 53 ++ dashboard/src/routes/apps/[slug]/+page.svelte | 195 ++++- 39 files changed, 3828 insertions(+), 53 deletions(-) create mode 100644 crates/executor-core/src/sdk/http.rs create mode 100644 crates/executor-core/tests/module_redaction_logging.rs create mode 100644 crates/executor-core/tests/sdk_http.rs create mode 100644 crates/manager-core/migrations/0017_cron_triggers.sql create mode 100644 crates/manager-core/src/cron_scheduler.rs create mode 100644 crates/manager-core/src/http_service.rs create mode 100644 crates/manager-core/src/ssrf.rs create mode 100644 crates/shared/src/http.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 77cfd02..0805a82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,92 @@ # PiCloud Changelog +## v1.1.4 — Outbound HTTP & Cron triggers (unreleased) + +Two surfaces. **`http::*`** lets Rhai scripts make outbound HTTP +requests (Slack webhooks, Stripe, third-party REST) fronted by an SSRF +deny-list applied to the *resolved IP* (DNS-rebinding defense), with +scheme/port restrictions, request/response body caps, and a layered +timeout. **Cron triggers** add the fourth concrete kind on the v1.1.1 +trigger framework: a scheduler task enqueues due triggers into the same +universal outbox the dispatcher already drains. + +### Added + +- **`http::{get,post,put,patch,delete,head,post_form,request}`** — outbound + HTTP SDK. Body and options are separate positional args + (`verb(url, body, opts)`); `opts` is + `{headers, timeout_ms, follow_redirects, max_redirects}` (unknown keys + throw). Body dispatch by type: Map/Array → JSON, String → text/plain, + `()` → none. Response is `#{ status, headers, body, body_raw }` with + `body` auto-parsed when the response is `application/json`. Non-2xx + does NOT throw (fetch-style); network/timeout/SSRF/size errors throw + with an `"http: …"` prefix. +- **SSRF deny-list** — applied to the resolved IP via a custom reqwest + `dns_resolver` (so it covers every redirect hop and defeats DNS + rebinding), plus a literal-IP check at URL-parse time. Blocks + loopback, RFC1918 private, link-local (incl. `169.254.169.254`), + carrier-grade NAT, multicast, reserved, IPv6 ULA/link-local/loopback, + and IPv4-mapped IPv6 (re-checked against the embedded v4 address). + The script-visible error carries a CIDR-category reason, never the IP. + `PICLOUD_HTTP_ALLOW_PRIVATE=true` disables it (dev-only; logs a startup + warning). +- **`HttpService` trait** (`picloud-shared`) + `HttpServiceImpl` + (manager-core, reqwest-backed). Wired into the `Services` bundle as + `http: Arc`. +- **`Capability::AppHttpRequest(AppId)`** — maps to the existing + `script:write` scope (any outbound request can exfiltrate data, so the + conservative write mapping is used). No new `Scope` variant — the + seven-scope commitment holds. Script-as-gate: skipped when the script + runs unauthenticated. +- **Cron triggers** — `POST /api/v1/admin/apps/{id}/triggers/cron` + (`script_id`, `schedule`, `timezone`, optional retry overrides). + 6-field cron expressions (with seconds) validated by the `cron` crate; + IANA timezones validated by `chrono-tz`. A scheduler task + (`spawn_cron_scheduler`, poll cadence `PICLOUD_CRON_TICK_INTERVAL_MS`, + default 30s) enqueues due triggers into the outbox; the existing + dispatcher delivers them. Catch-up policy: a trigger that missed N + windows fires exactly **once** on the next tick, not N times. +- **`ctx.event.cron`** — `{ schedule, timezone, scheduled_at, fired_at }` + for cron-trigger handlers (`ctx.event.source == "cron"`, + `ctx.event.op == "tick"`). +- **Dashboard Triggers tab** — admin-gated cron trigger create form + (target endpoint script, schedule, timezone dropdown) + triggers list + showing schedule / timezone / last-fired. + +### Changed + +- **Workspace version**: `1.1.3` → `1.1.4`. +- **Rhai SDK version**: `1.4` → `1.5` (additive — `http::*` SDK + + `ctx.event.cron`). The `Services` bundle constructor becomes + `Services::new(kv, docs, dead_letters, events, modules, http)`. +- **Dashboard version**: `0.9.0` → `0.10.0`. +- **`SdkCallCx`** — gains a `script_id` field (audit attribution + the + default outbound `User-Agent`, `picloud/ (script:)`). +- **Rhai pin tightened** — workspace dep `rhai = "1.19"` → `rhai = "=1.24"` + so future bumps of the non-semver-stable `internals` surface are + deliberate. +- **Module backend errors redacted** — `PicloudModuleResolver` now + surfaces a stable generic (`"module backend unavailable; check server + logs"`) to scripts and logs the original at error level, instead of + leaking the backend error verbatim (see v1.1.3 follow-up). + +### Migrations + +- `0017_cron_triggers.sql` — widens `triggers.kind` and + `outbox.source_kind` CHECK constraints to include `'cron'`; adds + `cron_trigger_details (trigger_id, schedule, timezone, last_fired_at)` + with a `last_fired_at` index. Additive — applies cleanly on a fresh DB + and on top of the v1.1.3 schema. + +### New environment variables + +- `PICLOUD_HTTP_ALLOW_PRIVATE` (default false; dev-only) — disable the + SSRF deny-list. +- `PICLOUD_HTTP_MAX_REQUEST_BODY_BYTES` / `PICLOUD_HTTP_MAX_RESPONSE_BODY_BYTES` + (default 10 MB each). +- `PICLOUD_CRON_TICK_INTERVAL_MS` (default 30000) — cron scheduler poll + cadence (floored at 1s). + ## v1.1.3 — Modules (unreleased) Real per-app Rhai module system. Scripts can `import "" as @@ -84,6 +171,21 @@ per-invocation compile cost; both invalidate on `updated_at` change. - **Route creation** — `POST /api/v1/admin/scripts/{id}/routes` returns 400 when the target script is `kind = 'module'`. +### Security fix + +- **Cross-app trigger target (CVE-class: broken access control).** In + v1.1.1 and v1.1.2, `POST /api/v1/admin/apps/{id}/triggers/{kv,docs,dead_letter}` + validated only that the caller could manage triggers on `{id}` — it + did **not** verify that the target `script_id` belonged to that same + app. A member with trigger-management rights on app A could therefore + register a trigger in A pointing at a script owned by app B, causing + B's script to execute on A's events (a cross-app isolation break). + v1.1.3 closes this: every trigger-create handler now loads the target + script and rejects it unless `script.app_id == path app_id` (and it is + not a module). **Upgrade recommendation:** anyone running a pre-v1.1.3 + multi-tenant deploy should upgrade and audit existing `triggers` rows + for any whose `script_id` resolves to a script in a different `app_id`. + ### Migrations - `0015_scripts_kind.sql` — adds `scripts.kind` with CHECK diff --git a/Cargo.lock b/Cargo.lock index 884bb1a..3307711 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -378,6 +378,28 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chrono-tz" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb" +dependencies = [ + "chrono", + "chrono-tz-build", + "phf", +] + +[[package]] +name = "chrono-tz-build" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1" +dependencies = [ + "parse-zoneinfo", + "phf", + "phf_codegen", +] + [[package]] name = "clap" version = "4.6.1" @@ -499,6 +521,17 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +[[package]] +name = "cron" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8c3e73077b4b4a6ab1ea5047c37c57aee77657bc8ecd6f29b0af082d0b0c07" +dependencies = [ + "chrono", + "nom", + "once_cell", +] + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -1326,6 +1359,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" version = "1.2.0" @@ -1346,6 +1385,16 @@ dependencies = [ "spin 0.5.2", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "normalize-line-endings" version = "0.3.0" @@ -1463,6 +1512,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "parse-zoneinfo" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24" +dependencies = [ + "regex", +] + [[package]] name = "password-hash" version = "0.5.0" @@ -1512,9 +1570,47 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.6", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "picloud" -version = "1.1.3" +version = "1.1.4" dependencies = [ "anyhow", "async-trait", @@ -1540,7 +1636,7 @@ dependencies = [ [[package]] name = "picloud-cli" -version = "1.1.3" +version = "1.1.4" dependencies = [ "anyhow", "assert_cmd", @@ -1561,7 +1657,7 @@ dependencies = [ [[package]] name = "picloud-executor" -version = "1.1.3" +version = "1.1.4" dependencies = [ "anyhow", "picloud-executor-core", @@ -1573,7 +1669,7 @@ dependencies = [ [[package]] name = "picloud-executor-core" -version = "1.1.3" +version = "1.1.4" dependencies = [ "async-trait", "base64", @@ -1590,12 +1686,14 @@ dependencies = [ "thiserror 1.0.69", "tokio", "tracing", + "tracing-subscriber", + "url", "uuid", ] [[package]] name = "picloud-manager" -version = "1.1.3" +version = "1.1.4" dependencies = [ "anyhow", "picloud-manager-core", @@ -1607,18 +1705,21 @@ dependencies = [ [[package]] name = "picloud-manager-core" -version = "1.1.3" +version = "1.1.4" dependencies = [ "argon2", "async-trait", "axum", "base64", "chrono", + "chrono-tz", + "cron", "data-encoding", "picloud-executor-core", "picloud-orchestrator-core", "picloud-shared", "rand 0.8.6", + "reqwest", "serde", "serde_json", "sha2", @@ -1632,7 +1733,7 @@ dependencies = [ [[package]] name = "picloud-orchestrator" -version = "1.1.3" +version = "1.1.4" dependencies = [ "anyhow", "picloud-orchestrator-core", @@ -1644,7 +1745,7 @@ dependencies = [ [[package]] name = "picloud-orchestrator-core" -version = "1.1.3" +version = "1.1.4" dependencies = [ "async-trait", "axum", @@ -1665,7 +1766,7 @@ dependencies = [ [[package]] name = "picloud-shared" -version = "1.1.3" +version = "1.1.4" dependencies = [ "async-trait", "chrono", @@ -2368,6 +2469,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.12" diff --git a/Cargo.toml b/Cargo.toml index b0c8c19..f2c38aa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ members = [ ] [workspace.package] -version = "1.1.3" +version = "1.1.4" edition = "2021" rust-version = "1.92" license = "MIT OR Apache-2.0" @@ -47,12 +47,16 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } # IDs + time uuid = { version = "1", features = ["v4", "serde"] } chrono = { version = "0.4", features = ["serde"] } +# Cron schedule parsing (v1.1.4 cron triggers) + IANA timezone resolution. +chrono-tz = "0.9" +cron = "0.12" # Async traits async-trait = "0.1" -# Rhai scripting -rhai = { version = "1.19", features = ["sync", "serde"] } +# Rhai scripting. Pinned exactly (`=1.24`) because the `internals` +# feature surface is not semver-stable — future bumps must be deliberate. +rhai = { version = "=1.24", features = ["sync", "serde"] } # Postgres (manager-core only — others stay DB-free) sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "json", "macros", "migrate"] } diff --git a/crates/executor-core/Cargo.toml b/crates/executor-core/Cargo.toml index 054f527..5c394b1 100644 --- a/crates/executor-core/Cargo.toml +++ b/crates/executor-core/Cargo.toml @@ -35,6 +35,13 @@ rand.workspace = true base64.workspace = true hex.workspace = true percent-encoding.workspace = true +# v1.1.4 — `http::post_form` uses `url::form_urlencoded` for correct +# application/x-www-form-urlencoded body encoding. +url.workspace = true [dev-dependencies] async-trait.workspace = true +# v1.1.4 §10a: capture tracing output to assert the original module +# backend error is logged at error level after being redacted from the +# script-visible message. +tracing-subscriber.workspace = true diff --git a/crates/executor-core/src/engine.rs b/crates/executor-core/src/engine.rs index b56d725..445a0a5 100644 --- a/crates/executor-core/src/engine.rs +++ b/crates/executor-core/src/engine.rs @@ -144,6 +144,7 @@ impl Engine { // capture cheap clones of the cx for use at script-call time. let cx = Arc::new(SdkCallCx { app_id: req.app_id, + script_id: req.script_id, principal: req.principal.clone(), execution_id: req.execution_id, request_id: req.request_id, @@ -388,6 +389,23 @@ fn trigger_event_to_dynamic(event: &TriggerEvent) -> Dynamic { ); m.insert("docs".into(), docs_map.into()); } + TriggerEvent::Cron { + schedule, + timezone, + scheduled_at, + fired_at, + } => { + // `ctx.event.op` is always "tick" for cron (the only op a + // schedule produces). Mirrors the docs/v1.1.x-design-notes + // §7 shape. + m.insert("op".into(), "tick".into()); + let mut cron_map = Map::new(); + cron_map.insert("schedule".into(), schedule.clone().into()); + cron_map.insert("timezone".into(), timezone.clone().into()); + cron_map.insert("scheduled_at".into(), scheduled_at.to_rfc3339().into()); + cron_map.insert("fired_at".into(), fired_at.to_rfc3339().into()); + m.insert("cron".into(), cron_map.into()); + } TriggerEvent::DeadLetter { dead_letter_id, original, diff --git a/crates/executor-core/src/module_resolver.rs b/crates/executor-core/src/module_resolver.rs index a4ffd1a..7659dc7 100644 --- a/crates/executor-core/src/module_resolver.rs +++ b/crates/executor-core/src/module_resolver.rs @@ -331,10 +331,22 @@ impl ModuleResolver for PicloudModuleResolver { ))); } Err(e) => { + // v1.1.4 §10a: redact the backend error before it + // reaches a script. In public-HTTP context (principal: + // None) the verbatim message (e.g. "connection refused") + // leaks internal infrastructure shape. Log the original + // at error level for operators; surface a stable generic. + tracing::error!( + target = "picloud::modules", + app_id = %self.cx.app_id, + module = path, + error = %e, + "module backend error" + ); return Err(Box::new(EvalAltResult::ErrorInModule( path.to_string(), Box::new(EvalAltResult::ErrorRuntime( - format!("module backend error: {e}").into(), + "module backend unavailable; check server logs".into(), pos, )), pos, diff --git a/crates/executor-core/src/sdk/http.rs b/crates/executor-core/src/sdk/http.rs new file mode 100644 index 0000000..7219dc0 --- /dev/null +++ b/crates/executor-core/src/sdk/http.rs @@ -0,0 +1,391 @@ +//! `http::` Rhai bridge — outbound HTTP from scripts (v1.1.4). +//! +//! ```rhai +//! let r = http::get("https://api.example.com/users/123"); +//! let r = http::get(url, #{ headers: #{ "Authorization": "Bearer x" }, timeout_ms: 5000 }); +//! let r = http::post(url, #{ text: "hello" }); // Map body → JSON +//! let r = http::post(url, "raw", #{ headers: #{ ... } }); // String body → text/plain +//! let r = http::post_form(url, #{ a: "1", b: "2" }); // form-encoded +//! let r = http::request("OPTIONS", url); +//! ``` +//! +//! **Argument shape (v1.1.4 decision):** body and options are separate +//! positional arguments — `verb(url, body, opts)` — not body-inside- +//! opts. This keeps the unknown-opt-key typo guard intact and resolves +//! the brief's internal contradiction (its Slack example passed a bare +//! body map). The `opts` vocabulary is exactly +//! `{headers, timeout_ms, follow_redirects, max_redirects}`; any other +//! key throws. +//! +//! Body dispatch (positional `body`): Map/Array → JSON + +//! `application/json`; String → raw + `text/plain`; Unit `()` → no +//! body. GET/HEAD ignore any body. +//! +//! Response is a Rhai map `#{ status, headers, body, body_raw }`: +//! `body` is the parsed JSON when the response is `application/json` +//! and parses; `()` for an empty body; otherwise the raw string. +//! +//! Errors follow `docs/sdk-shape.md`: network/timeout/SSRF/size failures +//! throw (`"http: "`); a non-2xx status does NOT throw — the +//! response map is returned, fetch-style. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use picloud_shared::{HttpError, HttpRequest, HttpResponse, HttpService, SdkCallCx, Services}; +use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module}; +use tokio::runtime::Handle as TokioHandle; + +use super::bridge::{dynamic_to_json, json_to_dynamic}; + +/// Bridge-side defaults (the service clamps server-side too). The +/// `MAX_*` ceilings stay `i64` because they're compared against the +/// raw `i64` the script passed (so an over-limit value is rejected, not +/// truncated); the defaults are `u32` to match the `Opts` fields. +const DEFAULT_TIMEOUT_MS: u32 = 30_000; +const MAX_TIMEOUT_MS: i64 = 60_000; +const DEFAULT_MAX_REDIRECTS: u32 = 5; +const MAX_REDIRECTS: i64 = 10; + +const ALLOWED_OPT_KEYS: [&str; 4] = ["headers", "timeout_ms", "follow_redirects", "max_redirects"]; + +pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc) { + let svc = services.http.clone(); + let mut module = Module::new(); + + // Bodyless verbs: (url) / (url, opts). + for verb in ["get", "head"] { + register_bodyless(&mut module, verb, &svc, &cx); + } + // Body verbs: (url) / (url, body) / (url, body, opts). + for verb in ["post", "put", "patch", "delete"] { + register_body(&mut module, verb, &svc, &cx); + } + register_post_form(&mut module, &svc, &cx); + register_request(&mut module, &svc, &cx); + + engine.register_static_module("http", module.into()); +} + +fn register_bodyless( + module: &mut Module, + verb: &'static str, + svc: &Arc, + cx: &Arc, +) { + { + let (svc, cx) = (svc.clone(), cx.clone()); + module.set_native_fn(verb, move |url: &str| { + invoke(&svc, &cx, verb, url, None, None) + }); + } + { + let (svc, cx) = (svc.clone(), cx.clone()); + module.set_native_fn(verb, move |url: &str, opts: Map| { + invoke(&svc, &cx, verb, url, None, Some(&opts)) + }); + } +} + +fn register_body( + module: &mut Module, + verb: &'static str, + svc: &Arc, + cx: &Arc, +) { + { + let (svc, cx) = (svc.clone(), cx.clone()); + module.set_native_fn(verb, move |url: &str| { + invoke(&svc, &cx, verb, url, None, None) + }); + } + { + let (svc, cx) = (svc.clone(), cx.clone()); + module.set_native_fn(verb, move |url: &str, body: Dynamic| { + invoke(&svc, &cx, verb, url, Some(body), None) + }); + } + { + let (svc, cx) = (svc.clone(), cx.clone()); + module.set_native_fn(verb, move |url: &str, body: Dynamic, opts: Map| { + invoke(&svc, &cx, verb, url, Some(body), Some(&opts)) + }); + } +} + +fn register_post_form(module: &mut Module, svc: &Arc, cx: &Arc) { + { + let (svc, cx) = (svc.clone(), cx.clone()); + module.set_native_fn("post_form", move |url: &str, form: Map| { + invoke_form(&svc, &cx, url, &form, None) + }); + } + { + let (svc, cx) = (svc.clone(), cx.clone()); + module.set_native_fn("post_form", move |url: &str, form: Map, opts: Map| { + invoke_form(&svc, &cx, url, &form, Some(&opts)) + }); + } +} + +fn register_request(module: &mut Module, svc: &Arc, cx: &Arc) { + { + let (svc, cx) = (svc.clone(), cx.clone()); + module.set_native_fn("request", move |method: &str, url: &str| { + invoke(&svc, &cx, method, url, None, None) + }); + } + { + let (svc, cx) = (svc.clone(), cx.clone()); + module.set_native_fn("request", move |method: &str, url: &str, body: Dynamic| { + invoke(&svc, &cx, method, url, Some(body), None) + }); + } + { + let (svc, cx) = (svc.clone(), cx.clone()); + module.set_native_fn( + "request", + move |method: &str, url: &str, body: Dynamic, opts: Map| { + invoke(&svc, &cx, method, url, Some(body), Some(&opts)) + }, + ); + } +} + +/// Parsed `opts` map. +struct Opts { + headers: BTreeMap, + timeout_ms: u32, + follow_redirects: bool, + max_redirects: u32, +} + +impl Default for Opts { + fn default() -> Self { + Self { + headers: BTreeMap::new(), + timeout_ms: DEFAULT_TIMEOUT_MS, + follow_redirects: true, + max_redirects: DEFAULT_MAX_REDIRECTS, + } + } +} + +fn parse_opts(opts: Option<&Map>) -> Result> { + let mut out = Opts::default(); + let Some(map) = opts else { + return Ok(out); + }; + for key in map.keys() { + if !ALLOWED_OPT_KEYS.contains(&key.as_str()) { + return Err(err(format!("unknown option key: {key}"))); + } + } + if let Some(h) = map.get("headers") { + let hm = h + .clone() + .try_cast::() + .ok_or_else(|| err("headers must be a map".to_string()))?; + for (k, v) in hm { + out.headers.insert(k.to_string(), dyn_to_string(&v)); + } + } + if let Some(t) = map.get("timeout_ms") { + let ms = t + .as_int() + .map_err(|_| err("timeout_ms must be an integer".to_string()))?; + if ms > MAX_TIMEOUT_MS { + return Err(err(format!( + "timeout_ms {ms} exceeds the {MAX_TIMEOUT_MS}ms maximum" + ))); + } + if ms > 0 { + out.timeout_ms = u32::try_from(ms).unwrap_or(u32::MAX); + } + } + if let Some(f) = map.get("follow_redirects") { + out.follow_redirects = f + .as_bool() + .map_err(|_| err("follow_redirects must be a bool".to_string()))?; + } + if let Some(m) = map.get("max_redirects") { + let n = m + .as_int() + .map_err(|_| err("max_redirects must be an integer".to_string()))?; + if n > MAX_REDIRECTS { + return Err(err(format!( + "max_redirects {n} exceeds the {MAX_REDIRECTS} maximum" + ))); + } + out.max_redirects = u32::try_from(n.max(0)).unwrap_or(0); + } + Ok(out) +} + +/// Encoded request body + the content-type chosen for it. +type EncodedBody = (Option>, Option); + +/// Dispatch a positional body by Rhai type. Returns the encoded bytes + +/// the chosen content-type. GET/HEAD callers pass `body = None`, so +/// this is never reached for them. +fn dispatch_body(body: Dynamic) -> Result> { + if body.is_unit() { + return Ok((None, None)); + } + if body.is_string() { + let s = body.into_string().unwrap_or_default(); + return Ok((Some(s.into_bytes()), Some("text/plain".to_string()))); + } + if body.is_map() || body.is_array() { + let json = dynamic_to_json(&body); + let bytes = serde_json::to_vec(&json) + .map_err(|e| err(format!("could not encode JSON body: {e}")))?; + return Ok((Some(bytes), Some("application/json".to_string()))); + } + // Scalars (int/float/bool) → JSON-encode for consistency. + let json = dynamic_to_json(&body); + let bytes = + serde_json::to_vec(&json).map_err(|e| err(format!("could not encode body: {e}")))?; + Ok((Some(bytes), Some("application/json".to_string()))) +} + +#[allow(clippy::needless_pass_by_value)] +fn invoke( + svc: &Arc, + cx: &Arc, + method: &str, + url: &str, + body: Option, + opts: Option<&Map>, +) -> Result> { + let opts = parse_opts(opts)?; + let method_uc = method.to_ascii_uppercase(); + let bodyless = matches!(method_uc.as_str(), "GET" | "HEAD"); + let (encoded, content_type) = if bodyless { + (None, None) + } else if let Some(b) = body { + dispatch_body(b)? + } else { + (None, None) + }; + + let req = HttpRequest { + method: method_uc, + url: url.to_string(), + headers: opts.headers, + body: encoded, + content_type, + timeout_ms: opts.timeout_ms, + follow_redirects: opts.follow_redirects, + max_redirects: opts.max_redirects, + script_id: Some(cx.script_id.to_string()), + }; + let resp = block_on(svc, cx, req)?; + Ok(response_to_dynamic(&resp)) +} + +#[allow(clippy::needless_pass_by_value)] +fn invoke_form( + svc: &Arc, + cx: &Arc, + url: &str, + form: &Map, + opts: Option<&Map>, +) -> Result> { + let opts = parse_opts(opts)?; + let mut serializer = url::form_urlencoded::Serializer::new(String::new()); + for (k, v) in form { + serializer.append_pair(k.as_str(), &dyn_to_string(v)); + } + let encoded = serializer.finish(); + + let req = HttpRequest { + method: "POST".to_string(), + url: url.to_string(), + headers: opts.headers, + body: Some(encoded.into_bytes()), + content_type: Some("application/x-www-form-urlencoded".to_string()), + timeout_ms: opts.timeout_ms, + follow_redirects: opts.follow_redirects, + max_redirects: opts.max_redirects, + script_id: Some(cx.script_id.to_string()), + }; + let resp = block_on(svc, cx, req)?; + Ok(response_to_dynamic(&resp)) +} + +fn response_to_dynamic(resp: &HttpResponse) -> Dynamic { + let mut m = Map::new(); + m.insert("status".into(), i64::from(resp.status).into()); + + let mut headers = Map::new(); + let mut content_type = String::new(); + for (k, v) in &resp.headers { + if k == "content-type" { + content_type.clone_from(v); + } + headers.insert(k.clone().into(), v.clone().into()); + } + m.insert("headers".into(), headers.into()); + + // `body`: parsed JSON when the response is JSON and parses; () when + // empty; otherwise the raw string. + let body = if resp.body_raw.is_empty() { + Dynamic::UNIT + } else if content_type + .to_ascii_lowercase() + .starts_with("application/json") + { + match serde_json::from_str::(&resp.body_raw) { + Ok(json) => json_to_dynamic(json), + Err(_) => resp.body_raw.clone().into(), + } + } else { + resp.body_raw.clone().into() + }; + m.insert("body".into(), body); + m.insert("body_raw".into(), resp.body_raw.clone().into()); + m.into() +} + +fn dyn_to_string(v: &Dynamic) -> String { + if v.is_string() { + v.clone().into_string().unwrap_or_default() + } else { + v.to_string() + } +} + +// Rhai's native-fn error channel is `Box`, so these +// helpers return the boxed form the call sites need. +#[allow(clippy::unnecessary_box_returns)] +fn err(msg: String) -> Box { + EvalAltResult::ErrorRuntime(format!("http: {msg}").into(), rhai::Position::NONE).into() +} + +/// Run the async service call from the synchronous Rhai context. Same +/// pattern as `kv`/`docs`: the script runs under `spawn_blocking`, so a +/// runtime handle is reachable and blocking on it is correct. +fn block_on( + svc: &Arc, + cx: &Arc, + req: HttpRequest, +) -> Result> { + let handle = TokioHandle::try_current().map_err(|e| -> Box { + EvalAltResult::ErrorRuntime( + format!("http: no tokio runtime available: {e}").into(), + rhai::Position::NONE, + ) + .into() + })?; + let svc = svc.clone(); + let cx = cx.clone(); + handle + .block_on(async move { svc.request(&cx, req).await }) + .map_err(map_http_err) +} + +#[allow(clippy::unnecessary_box_returns)] +fn map_http_err(e: HttpError) -> Box { + EvalAltResult::ErrorRuntime(format!("http: {e}").into(), rhai::Position::NONE).into() +} diff --git a/crates/executor-core/src/sdk/mod.rs b/crates/executor-core/src/sdk/mod.rs index 574bd3f..138fa73 100644 --- a/crates/executor-core/src/sdk/mod.rs +++ b/crates/executor-core/src/sdk/mod.rs @@ -15,6 +15,7 @@ pub mod bridge; pub mod cx; pub mod dead_letters; pub mod docs; +pub mod http; pub mod kv; pub mod stdlib; @@ -35,5 +36,6 @@ use rhai::Engine as RhaiEngine; pub fn register_all(engine: &mut RhaiEngine, services: &Services, cx: Arc) { kv::register(engine, services, cx.clone()); docs::register(engine, services, cx.clone()); - dead_letters::register(engine, services, cx); + dead_letters::register(engine, services, cx.clone()); + http::register(engine, services, cx); } diff --git a/crates/executor-core/tests/module_redaction_logging.rs b/crates/executor-core/tests/module_redaction_logging.rs new file mode 100644 index 0000000..ae35992 --- /dev/null +++ b/crates/executor-core/tests/module_redaction_logging.rs @@ -0,0 +1,127 @@ +//! v1.1.4 §10a: the original module backend error MUST be logged at +//! error level (so operators can still diagnose), even though it is +//! redacted from the script-visible error. +//! +//! This test owns the process-global tracing subscriber, so it lives in +//! its own integration-test binary (one `set_global_default` per +//! process). A unique sentinel in the backend error keeps the assertion +//! robust against any concurrently-running test's log output. + +use std::collections::BTreeMap; +use std::io::Write; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits}; +use picloud_shared::{ + AppId, ExecutionId, ModuleScript, ModuleSource, ModuleSourceError, NoopDeadLetterService, + NoopDocsService, NoopEventEmitter, NoopHttpService, NoopKvService, RequestId, ScriptId, + ScriptSandbox, SdkCallCx, Services, +}; +use serde_json::Value; +use tracing_subscriber::fmt::MakeWriter; + +const SENTINEL: &str = "connection refused PICLOUD-SENTINEL-9f3a"; + +struct FailingSource; + +#[async_trait] +impl ModuleSource for FailingSource { + async fn lookup( + &self, + _cx: &SdkCallCx, + _name: &str, + ) -> Result, ModuleSourceError> { + Err(ModuleSourceError::Backend(SENTINEL.to_string())) + } +} + +/// `MakeWriter` that appends to a shared buffer. +#[derive(Clone)] +struct SharedBuf(Arc>>); + +impl Write for SharedBuf { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(buf); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +impl<'a> MakeWriter<'a> for SharedBuf { + type Writer = SharedBuf; + fn make_writer(&'a self) -> Self::Writer { + self.clone() + } +} + +fn req(app_id: AppId) -> ExecRequest { + let execution_id = ExecutionId::new(); + ExecRequest { + execution_id, + request_id: RequestId::new(), + script_id: ScriptId::new(), + script_name: "redaction-test".into(), + invocation_type: InvocationType::Http, + path: "/x".into(), + headers: BTreeMap::new(), + body: Value::Null, + params: BTreeMap::new(), + query: BTreeMap::new(), + rest: String::new(), + sandbox_overrides: ScriptSandbox::default(), + app_id, + principal: None, + trigger_depth: 0, + root_execution_id: execution_id, + is_dead_letter_handler: false, + event: None, + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn original_backend_error_is_logged_at_error_level() { + let buf = Arc::new(Mutex::new(Vec::::new())); + let subscriber = tracing_subscriber::fmt() + .with_writer(SharedBuf(buf.clone())) + .with_max_level(tracing::Level::ERROR) + .with_ansi(false) + .finish(); + tracing::subscriber::set_global_default(subscriber) + .expect("this test owns the global subscriber for its binary"); + + let services = Services::new( + Arc::new(NoopKvService), + Arc::new(NoopDocsService), + Arc::new(NoopDeadLetterService), + Arc::new(NoopEventEmitter), + Arc::new(FailingSource), + Arc::new(NoopHttpService), + ); + let engine = Engine::new(Limits::default(), services); + + let err = engine + .execute(r#"import "x" as x; 1"#, req(AppId::new())) + .expect_err("backend error should surface"); + + // Script-visible: redacted. + let msg = format!("{err:?}"); + assert!(msg.contains("module backend unavailable"), "got {msg}"); + assert!( + !msg.contains("PICLOUD-SENTINEL"), + "script error leaked the original: {msg}" + ); + + // Operator log: the original sentinel IS present, at ERROR level. + let logged = String::from_utf8(buf.lock().unwrap().clone()).unwrap(); + assert!( + logged.contains(SENTINEL), + "original backend error should be logged; captured: {logged}" + ); + assert!( + logged.contains("ERROR"), + "should be logged at error level; captured: {logged}" + ); +} diff --git a/crates/executor-core/tests/modules.rs b/crates/executor-core/tests/modules.rs index d974f81..3cf3d92 100644 --- a/crates/executor-core/tests/modules.rs +++ b/crates/executor-core/tests/modules.rs @@ -17,8 +17,8 @@ use chrono::{DateTime, Utc}; use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits}; use picloud_shared::{ AppId, ExecutionId, ModuleScript, ModuleSource, ModuleSourceError, NoopDeadLetterService, - NoopDocsService, NoopEventEmitter, NoopKvService, RequestId, ScriptId, ScriptSandbox, - SdkCallCx, Services, + NoopDocsService, NoopEventEmitter, NoopHttpService, NoopKvService, RequestId, ScriptId, + ScriptSandbox, SdkCallCx, Services, }; use tokio::sync::Mutex; @@ -96,6 +96,7 @@ fn services_with(modules: Arc) -> Services { Arc::new(NoopDeadLetterService), Arc::new(NoopEventEmitter), modules, + Arc::new(NoopHttpService), ) } @@ -321,20 +322,28 @@ async fn resolver_runtime_validation_rejects_top_level_expr() { ); } +/// v1.1.4 §10a regression: the backend error must be REDACTED before +/// it reaches a script. The verbatim message (which can leak internal +/// infrastructure shape, e.g. "connection refused") must not appear; +/// the script sees only a stable generic. #[tokio::test(flavor = "multi_thread")] -async fn resolver_backend_error_surfaces() { +async fn resolver_backend_error_is_redacted_from_script() { let source = CountingModuleSource::new(); let app_id = AppId::new(); - *source.fail_with.lock().await = Some("simulated db outage".into()); + *source.fail_with.lock().await = Some("connection refused to 10.1.2.3:5432".into()); let engine = engine_with(source); let err = engine .execute(r#"import "x" as x; 1"#, req(app_id)) .expect_err("backend error should propagate"); - let msg = format!("{err:?}").to_lowercase(); + let msg = format!("{err:?}"); assert!( - msg.contains("simulated") || msg.contains("backend"), - "expected backend-error message, got {msg}" + msg.contains("module backend unavailable"), + "expected redacted generic message, got {msg}" + ); + assert!( + !msg.contains("connection refused") && !msg.contains("10.1.2.3"), + "redacted message must not leak the backend error, got {msg}" ); } diff --git a/crates/executor-core/tests/sdk_docs.rs b/crates/executor-core/tests/sdk_docs.rs index 6f793fc..62c6e74 100644 --- a/crates/executor-core/tests/sdk_docs.rs +++ b/crates/executor-core/tests/sdk_docs.rs @@ -11,8 +11,8 @@ use chrono::Utc; use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits}; use picloud_shared::{ AppId, DocId, DocRow, DocsError, DocsListPage, DocsService, ExecutionId, NoopDeadLetterService, - NoopEventEmitter, NoopKvService, NoopModuleSource, RequestId, ScriptId, ScriptSandbox, - SdkCallCx, Services, + NoopEventEmitter, NoopHttpService, NoopKvService, NoopModuleSource, RequestId, ScriptId, + ScriptSandbox, SdkCallCx, Services, }; use serde_json::{json, Value}; use tokio::sync::Mutex; @@ -227,6 +227,7 @@ fn make_engine() -> Arc { Arc::new(NoopDeadLetterService), Arc::new(NoopEventEmitter), Arc::new(NoopModuleSource), + Arc::new(NoopHttpService), ); Arc::new(Engine::new(Limits::default(), services)) } diff --git a/crates/executor-core/tests/sdk_http.rs b/crates/executor-core/tests/sdk_http.rs new file mode 100644 index 0000000..11a0f2c --- /dev/null +++ b/crates/executor-core/tests/sdk_http.rs @@ -0,0 +1,334 @@ +//! Bridge integration for the `http::*` SDK (v1.1.4). +//! +//! Runs a real Rhai engine under `spawn_blocking` against an in-memory +//! `HttpService` fake that records the last request and returns a +//! configured response (or error). This exercises the full bridge: +//! option parsing, body dispatch, response→map projection, the +//! throw-on-network-error / no-throw-on-non-2xx convention, and that +//! `cx.app_id` / `cx.script_id` are forwarded for attribution. + +use std::collections::BTreeMap; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits}; +use picloud_shared::{ + AppId, ExecutionId, HttpError, HttpRequest, HttpResponse, HttpService, NoopDeadLetterService, + NoopDocsService, NoopEventEmitter, NoopKvService, NoopModuleSource, RequestId, ScriptId, + ScriptSandbox, Services, +}; +use serde_json::{json, Value}; + +/// What the fake returns. Either a canned response or an error. +#[derive(Clone)] +enum Behavior { + Respond(HttpResponse), + Fail(String), // becomes HttpError::Network +} + +#[derive(Default)] +struct Recorded { + last: Option, + last_app: Option, + last_script: Option, +} + +struct FakeHttp { + behavior: Behavior, + recorded: Mutex, +} + +impl FakeHttp { + fn responding(status: u16, content_type: &str, body: &str) -> Arc { + let mut headers = BTreeMap::new(); + headers.insert("content-type".into(), content_type.into()); + Arc::new(Self { + behavior: Behavior::Respond(HttpResponse { + status, + headers, + body_raw: body.into(), + }), + recorded: Mutex::new(Recorded::default()), + }) + } + + fn failing(msg: &str) -> Arc { + Arc::new(Self { + behavior: Behavior::Fail(msg.into()), + recorded: Mutex::new(Recorded::default()), + }) + } +} + +#[async_trait] +impl HttpService for FakeHttp { + async fn request( + &self, + cx: &picloud_shared::SdkCallCx, + req: HttpRequest, + ) -> Result { + { + let mut r = self.recorded.lock().unwrap(); + r.last = Some(req.clone()); + r.last_app = Some(cx.app_id); + r.last_script = Some(cx.script_id.to_string()); + } + match &self.behavior { + Behavior::Respond(resp) => Ok(resp.clone()), + Behavior::Fail(msg) => Err(HttpError::Network(msg.clone())), + } + } +} + +fn engine_with(http: Arc) -> Arc { + let services = Services::new( + Arc::new(NoopKvService), + Arc::new(NoopDocsService), + Arc::new(NoopDeadLetterService), + Arc::new(NoopEventEmitter), + Arc::new(NoopModuleSource), + http, + ); + Arc::new(Engine::new(Limits::default(), services)) +} + +fn baseline_request(app_id: AppId, script_id: ScriptId) -> ExecRequest { + let execution_id = ExecutionId::new(); + ExecRequest { + execution_id, + request_id: RequestId::new(), + script_id, + script_name: "http-test".into(), + invocation_type: InvocationType::Http, + path: "/http-test".into(), + headers: BTreeMap::new(), + body: Value::Null, + params: BTreeMap::new(), + query: BTreeMap::new(), + rest: String::new(), + sandbox_overrides: ScriptSandbox::default(), + app_id, + principal: None, + trigger_depth: 0, + root_execution_id: execution_id, + is_dead_letter_handler: false, + event: None, + } +} + +async fn run(engine: Arc, src: &str, req: ExecRequest) -> Value { + let src = src.to_string(); + tokio::task::spawn_blocking(move || engine.execute(&src, req)) + .await + .expect("spawn_blocking should not panic") + .expect("script execution should succeed") + .body +} + +async fn run_err(engine: Arc, src: &str, req: ExecRequest) -> String { + let src = src.to_string(); + let err = tokio::task::spawn_blocking(move || engine.execute(&src, req)) + .await + .unwrap() + .expect_err("script should throw"); + format!("{err:?}") +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_returns_status_and_json_body() { + let http = FakeHttp::responding(200, "application/json", r#"{"ok":true,"n":7}"#); + let engine = engine_with(http.clone()); + let src = r#" + let r = http::get("https://api.example.com/x"); + #{ status: r.status, ok: r.body.ok, n: r.body.n } + "#; + let body = run(engine, src, baseline_request(AppId::new(), ScriptId::new())).await; + assert_eq!(body, json!({ "status": 200, "ok": true, "n": 7 })); + // GET carries no body. + assert!(http + .recorded + .lock() + .unwrap() + .last + .as_ref() + .unwrap() + .body + .is_none()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn non_json_body_stays_string() { + let http = FakeHttp::responding(200, "text/plain", "plain text"); + let engine = engine_with(http); + let src = r#"http::get("https://x/").body"#; + let body = run(engine, src, baseline_request(AppId::new(), ScriptId::new())).await; + assert_eq!(body, json!("plain text")); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn empty_body_is_unit() { + let http = FakeHttp::responding(204, "text/plain", ""); + let engine = engine_with(http); + let src = r#" + let r = http::get("https://x/"); + #{ is_unit: r.body == (), raw: r.body_raw } + "#; + let body = run(engine, src, baseline_request(AppId::new(), ScriptId::new())).await; + assert_eq!(body, json!({ "is_unit": true, "raw": "" })); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn post_map_body_is_json_encoded() { + let http = FakeHttp::responding(200, "application/json", "{}"); + let engine = engine_with(http.clone()); + let src = r#"http::post("https://hooks/x", #{ text: "hello", n: 3 }).status"#; + let _ = run(engine, src, baseline_request(AppId::new(), ScriptId::new())).await; + let rec = http.recorded.lock().unwrap(); + let req = rec.last.as_ref().unwrap(); + assert_eq!(req.method, "POST"); + assert_eq!(req.content_type.as_deref(), Some("application/json")); + let sent: Value = serde_json::from_slice(req.body.as_ref().unwrap()).unwrap(); + assert_eq!(sent, json!({ "text": "hello", "n": 3 })); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn post_string_body_is_text_plain() { + let http = FakeHttp::responding(200, "text/plain", "ok"); + let engine = engine_with(http.clone()); + let src = r#"http::post("https://x/", "raw payload").status"#; + let _ = run(engine, src, baseline_request(AppId::new(), ScriptId::new())).await; + let rec = http.recorded.lock().unwrap(); + let req = rec.last.as_ref().unwrap(); + assert_eq!(req.content_type.as_deref(), Some("text/plain")); + assert_eq!(req.body.as_deref(), Some(&b"raw payload"[..])); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn post_unit_body_sends_nothing() { + let http = FakeHttp::responding(200, "text/plain", "ok"); + let engine = engine_with(http.clone()); + let src = r#"http::post("https://x/", ()).status"#; + let _ = run(engine, src, baseline_request(AppId::new(), ScriptId::new())).await; + assert!(http + .recorded + .lock() + .unwrap() + .last + .as_ref() + .unwrap() + .body + .is_none()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn custom_headers_and_timeout_forwarded() { + let http = FakeHttp::responding(200, "text/plain", "ok"); + let engine = engine_with(http.clone()); + let src = r#" + http::get("https://x/", #{ + headers: #{ "Authorization": "Bearer t0ken" }, + timeout_ms: 4200, + }).status + "#; + let _ = run(engine, src, baseline_request(AppId::new(), ScriptId::new())).await; + let rec = http.recorded.lock().unwrap(); + let req = rec.last.as_ref().unwrap(); + assert_eq!( + req.headers.get("Authorization").map(String::as_str), + Some("Bearer t0ken") + ); + assert_eq!(req.timeout_ms, 4200); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unknown_option_key_throws() { + let http = FakeHttp::responding(200, "text/plain", "ok"); + let engine = engine_with(http); + let src = r#"http::get("https://x/", #{ timeoutms: 1000 })"#; // typo + let err = run_err(engine, src, baseline_request(AppId::new(), ScriptId::new())).await; + assert!(err.contains("unknown option key"), "got {err}"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn timeout_above_max_throws() { + let http = FakeHttp::responding(200, "text/plain", "ok"); + let engine = engine_with(http); + let src = r#"http::get("https://x/", #{ timeout_ms: 99999 })"#; + let err = run_err(engine, src, baseline_request(AppId::new(), ScriptId::new())).await; + assert!(err.contains("maximum"), "got {err}"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn non_2xx_does_not_throw() { + let http = FakeHttp::responding(503, "text/plain", "down"); + let engine = engine_with(http); + let src = r#"http::get("https://x/").status"#; + let body = run(engine, src, baseline_request(AppId::new(), ScriptId::new())).await; + assert_eq!(body, json!(503)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn network_error_throws_with_http_prefix() { + let http = FakeHttp::failing("connection refused"); + let engine = engine_with(http); + let src = r#"http::get("https://x/")"#; + let err = run_err(engine, src, baseline_request(AppId::new(), ScriptId::new())).await; + assert!(err.contains("http:"), "expected http: prefix, got {err}"); + assert!(err.contains("connection refused"), "got {err}"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn post_form_url_encodes() { + let http = FakeHttp::responding(200, "text/plain", "ok"); + let engine = engine_with(http.clone()); + let src = r#"http::post_form("https://x/login", #{ user: "alice", pw: "p@ss word" }).status"#; + let _ = run(engine, src, baseline_request(AppId::new(), ScriptId::new())).await; + let rec = http.recorded.lock().unwrap(); + let req = rec.last.as_ref().unwrap(); + assert_eq!( + req.content_type.as_deref(), + Some("application/x-www-form-urlencoded") + ); + let body = String::from_utf8(req.body.clone().unwrap()).unwrap(); + // order is map iteration order; assert both pairs present, encoded. + assert!(body.contains("user=alice"), "got {body}"); + assert!(body.contains("pw=p%40ss+word"), "got {body}"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn request_escape_hatch_arbitrary_method() { + let http = FakeHttp::responding(200, "text/plain", "ok"); + let engine = engine_with(http.clone()); + let src = r#"http::request("OPTIONS", "https://x/").status"#; + let _ = run(engine, src, baseline_request(AppId::new(), ScriptId::new())).await; + assert_eq!( + http.recorded.lock().unwrap().last.as_ref().unwrap().method, + "OPTIONS" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn default_user_agent_carries_script_id() { + let http = FakeHttp::responding(200, "text/plain", "ok"); + let engine = engine_with(http.clone()); + let script_id = ScriptId::new(); + let src = r#"http::get("https://x/").status"#; + let _ = run(engine, src, baseline_request(AppId::new(), script_id)).await; + let rec = http.recorded.lock().unwrap(); + // The bridge forwards script_id on the request; the manager-core + // impl turns it into the User-Agent. Here we assert the forward. + assert_eq!( + rec.last.as_ref().unwrap().script_id.as_deref(), + Some(script_id.to_string().as_str()) + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn cx_app_id_forwarded_for_attribution() { + let http = FakeHttp::responding(200, "text/plain", "ok"); + let engine = engine_with(http.clone()); + let app = AppId::new(); + let src = r#"http::get("https://x/").status"#; + let _ = run(engine, src, baseline_request(app, ScriptId::new())).await; + assert_eq!(http.recorded.lock().unwrap().last_app, Some(app)); +} diff --git a/crates/executor-core/tests/sdk_kv.rs b/crates/executor-core/tests/sdk_kv.rs index 03d5625..343e0ad 100644 --- a/crates/executor-core/tests/sdk_kv.rs +++ b/crates/executor-core/tests/sdk_kv.rs @@ -11,7 +11,8 @@ use async_trait::async_trait; use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits}; use picloud_shared::{ AppId, ExecutionId, KvError, KvListPage, KvService, NoopDeadLetterService, NoopDocsService, - NoopEventEmitter, NoopModuleSource, RequestId, ScriptId, ScriptSandbox, SdkCallCx, Services, + NoopEventEmitter, NoopHttpService, NoopModuleSource, RequestId, ScriptId, ScriptSandbox, + SdkCallCx, Services, }; use serde_json::{json, Value}; use tokio::sync::Mutex; @@ -105,6 +106,7 @@ fn make_engine() -> Arc { Arc::new(NoopDeadLetterService), Arc::new(NoopEventEmitter), Arc::new(NoopModuleSource), + Arc::new(NoopHttpService), ); Arc::new(Engine::new(Limits::default(), services)) } diff --git a/crates/manager-core/Cargo.toml b/crates/manager-core/Cargo.toml index 90e62fd..331a45b 100644 --- a/crates/manager-core/Cargo.toml +++ b/crates/manager-core/Cargo.toml @@ -23,8 +23,11 @@ tokio.workspace = true tracing.workspace = true uuid.workspace = true chrono.workspace = true +chrono-tz.workspace = true +cron.workspace = true sqlx.workspace = true url.workspace = true +reqwest.workspace = true argon2.workspace = true sha2.workspace = true diff --git a/crates/manager-core/migrations/0017_cron_triggers.sql b/crates/manager-core/migrations/0017_cron_triggers.sql new file mode 100644 index 0000000..28ff440 --- /dev/null +++ b/crates/manager-core/migrations/0017_cron_triggers.sql @@ -0,0 +1,43 @@ +-- v1.1.4: Extend the triggers framework to recognise `cron` as the +-- fourth concrete kind (after `kv` v1.1.1, `dead_letter` v1.1.1, `docs` +-- v1.1.2). Mirrors the 0014 docs extension: two CHECK constraints widen +-- (strictly gaining `'cron'`), one new detail table. +-- +-- Cron rows route through the SAME generic dispatcher path as kv/docs/ +-- dead_letter (single match-arm extension on the Rust side). The only +-- new machinery is a scheduler task that enqueues due cron triggers +-- into the outbox; dispatch itself is unchanged. + +-- Extend triggers.kind to include 'cron'. No existing row carries a +-- value outside the widened set, so the drop+add is safe. +ALTER TABLE triggers DROP CONSTRAINT triggers_kind_check; +ALTER TABLE triggers ADD CONSTRAINT triggers_kind_check + CHECK (kind IN ('kv', 'dead_letter', 'docs', 'cron')); + +-- Extend outbox.source_kind to include 'cron'. v1.1.x's existing +-- source_kinds ('http', 'kv', 'dead_letter', 'docs') stay. +ALTER TABLE outbox DROP CONSTRAINT outbox_source_kind_check; +ALTER TABLE outbox ADD CONSTRAINT outbox_source_kind_check + CHECK (source_kind IN ('http', 'kv', 'dead_letter', 'docs', 'cron')); + +-- One row per cron trigger. +-- schedule — 6-field cron expression (with seconds), validated +-- at insert time by the `cron` crate. +-- timezone — IANA tz name (e.g. "America/Los_Angeles"), validated +-- via chrono-tz. Required so schedules like "every +-- weekday at 9am" are unambiguous. Defaults to UTC. +-- last_fired_at — set transactionally with each enqueue. NULL until +-- the trigger first fires. The scheduler computes the +-- next fire time in-process from +-- (schedule, timezone, last_fired_at); there is no +-- stored next_fire column (kept stateless on purpose). +CREATE TABLE cron_trigger_details ( + trigger_id UUID PRIMARY KEY REFERENCES triggers(id) ON DELETE CASCADE, + schedule TEXT NOT NULL, + timezone TEXT NOT NULL DEFAULT 'UTC', + last_fired_at TIMESTAMPTZ +); + +-- Hot lookup for the scheduler: "all enabled cron triggers due now" +-- scans by last_fired_at. +CREATE INDEX idx_cron_triggers_due ON cron_trigger_details (last_fired_at); diff --git a/crates/manager-core/src/authz.rs b/crates/manager-core/src/authz.rs index a27861f..e7457b6 100644 --- a/crates/manager-core/src/authz.rs +++ b/crates/manager-core/src/authz.rs @@ -72,6 +72,12 @@ pub enum Capability { /// shape as KV write — granted to `editor`+, maps to /// `script:write` on API keys. AppDocsWrite(AppId), + /// Make an outbound HTTP request from a script in this app + /// (v1.1.4). Maps to `script:write` on API keys: any outbound + /// request can exfiltrate data — including read methods like GET — + /// so the conservative write mapping is correct. Splitting + /// read/write is a v1.2+ refinement. Granted to `editor`+. + AppHttpRequest(AppId), /// Create / list / delete triggers for this app (v1.1.1). Maps to /// `app:admin` on API keys — triggers are app-configuration acts /// rather than data-plane access. Granted to `app_admin`+. @@ -101,6 +107,7 @@ impl Capability { | Self::AppKvWrite(id) | Self::AppDocsRead(id) | Self::AppDocsWrite(id) + | Self::AppHttpRequest(id) | Self::AppManageTriggers(id) | Self::AppDeadLetterManage(id) => Some(id), } @@ -118,9 +125,10 @@ impl Capability { Scope::InstanceAdmin } Self::AppRead(_) | Self::AppKvRead(_) | Self::AppDocsRead(_) => Scope::ScriptRead, - Self::AppWriteScript(_) | Self::AppKvWrite(_) | Self::AppDocsWrite(_) => { - Scope::ScriptWrite - } + Self::AppWriteScript(_) + | Self::AppKvWrite(_) + | Self::AppDocsWrite(_) + | Self::AppHttpRequest(_) => Scope::ScriptWrite, Self::AppWriteRoute(_) => Scope::RouteWrite, Self::AppManageDomains(_) => Scope::DomainManage, Self::AppAdmin(_) | Self::AppManageTriggers(_) | Self::AppDeadLetterManage(_) => { @@ -277,6 +285,7 @@ const fn role_satisfies(role: AppRole, cap: Capability) -> bool { | Capability::AppWriteRoute(_) | Capability::AppKvWrite(_) | Capability::AppDocsWrite(_) + | Capability::AppHttpRequest(_) ); let in_app_admin = in_editor || matches!( diff --git a/crates/manager-core/src/cron_scheduler.rs b/crates/manager-core/src/cron_scheduler.rs new file mode 100644 index 0000000..f4c4426 --- /dev/null +++ b/crates/manager-core/src/cron_scheduler.rs @@ -0,0 +1,297 @@ +//! Cron scheduler — the v1.1.4 time-based trigger source. +//! +//! A single tokio task polls `cron_trigger_details` on a tick (default +//! 30s; `PICLOUD_CRON_TICK_INTERVAL_MS`). For each enabled cron trigger +//! whose next scheduled fire is due, it enqueues ONE outbox row +//! (`source_kind = 'cron'`) and updates `last_fired_at` — both in the +//! same transaction, claimed via `FOR UPDATE SKIP LOCKED` so a future +//! multi-node deploy can't double-fire. +//! +//! The scheduler does NOT dispatch or touch the `ExecutionGate`: it only +//! enqueues. The existing dispatcher picks the row up and acquires the +//! gate exactly as it does for kv/docs/dead_letter rows. +//! +//! **Catch-up policy (matches the brief):** a trigger that missed N fire +//! windows since `last_fired_at` fires exactly ONCE on the next tick, +//! not N times. This falls out of the design: [`next_due`] returns a +//! single canonical scheduled time (the first slot after the reference +//! point), and after firing we set `last_fired_at = now`, so the next +//! tick computes from `now` and sees only future slots. Backfilling +//! missed windows is intentionally out of scope (an explicit replay +//! action is the v1.2+ escape hatch). + +use std::str::FromStr; +use std::time::Duration; + +use chrono::{DateTime, Utc}; +use chrono_tz::Tz; +use cron::Schedule; +use picloud_shared::TriggerEvent; +use sqlx::PgPool; +use uuid::Uuid; + +/// Validate a 6-field cron expression. Returns the parse error message +/// on failure. +/// +/// # Errors +/// +/// Returns the underlying parse error string when `schedule` is not a +/// valid cron expression. +pub fn validate_schedule(schedule: &str) -> Result<(), String> { + Schedule::from_str(schedule) + .map(|_| ()) + .map_err(|e| e.to_string()) +} + +/// Validate an IANA timezone name (e.g. `America/Los_Angeles`). +/// +/// # Errors +/// +/// Returns an error string when `timezone` is not a known IANA name. +pub fn validate_timezone(timezone: &str) -> Result<(), String> { + Tz::from_str(timezone) + .map(|_| ()) + .map_err(|_| format!("unknown IANA timezone: {timezone}")) +} + +/// Compute whether a cron trigger is due, and if so its canonical +/// scheduled-at moment (UTC). +/// +/// Returns `Some(scheduled_at)` when the first scheduled slot after the +/// reference point (`last_fired_at`, or `created_at` if never fired) is +/// at/before `now`; `None` otherwise. Returns `None` if the schedule or +/// timezone fails to parse (the row is skipped — it should never have +/// been inserted, since the admin endpoint validates). +#[must_use] +pub fn next_due( + schedule: &str, + timezone: &str, + last_fired_at: Option>, + created_at: DateTime, + now: DateTime, +) -> Option> { + let sched = Schedule::from_str(schedule).ok()?; + let tz = Tz::from_str(timezone).ok()?; + // Reference: the last actual fire, or creation if never fired. A + // never-fired trigger fires at its first slot at/after creation. + let base = last_fired_at.unwrap_or(created_at); + let base_tz = base.with_timezone(&tz); + let next = sched.after(&base_tz).next()?; + let next_utc = next.with_timezone(&Utc); + (next_utc <= now).then_some(next_utc) +} + +/// Spawn the scheduler loop. Runs for the process lifetime. +pub fn spawn_cron_scheduler(pool: PgPool, tick_interval_ms: u32) { + // Floor the tick at 1s so a misconfigured 0 can't spin. + let interval = Duration::from_millis(u64::from(tick_interval_ms).max(1_000)); + tokio::spawn(async move { + let mut ticker = tokio::time::interval(interval); + // Skip the immediate first fire so we don't race startup. + ticker.tick().await; + loop { + ticker.tick().await; + if let Err(e) = tick(&pool, Utc::now()).await { + tracing::warn!(?e, "cron scheduler tick errored"); + } + } + }); +} + +#[derive(sqlx::FromRow)] +struct DueRow { + id: Uuid, + app_id: Uuid, + script_id: Uuid, + registered_by_principal: Uuid, + created_at: DateTime, + schedule: String, + timezone: String, + last_fired_at: Option>, +} + +/// One scheduler tick: claim enabled cron rows, enqueue the due ones, +/// bump `last_fired_at`. Returns the number of triggers fired. +async fn tick(pool: &PgPool, now: DateTime) -> Result { + let mut tx = pool.begin().await?; + let rows: Vec = sqlx::query_as( + "SELECT t.id, t.app_id, t.script_id, t.registered_by_principal, t.created_at, \ + d.schedule, d.timezone, d.last_fired_at \ + FROM cron_trigger_details d \ + JOIN triggers t ON t.id = d.trigger_id \ + WHERE t.enabled = TRUE \ + FOR UPDATE OF d SKIP LOCKED", + ) + .fetch_all(&mut *tx) + .await?; + + let mut fired = 0usize; + for r in rows { + let Some(scheduled_at) = + next_due(&r.schedule, &r.timezone, r.last_fired_at, r.created_at, now) + else { + continue; + }; + + let event = TriggerEvent::Cron { + schedule: r.schedule.clone(), + timezone: r.timezone.clone(), + scheduled_at, + fired_at: now, + }; + let payload = serde_json::to_value(&event) + .map_err(|e| sqlx::Error::Decode(Box::new(std::io::Error::other(e))))?; + + // Enqueue exactly one outbox row. Relies on the same column + // defaults the OutboxEventEmitter uses (next_attempt_at = NOW(), + // attempt_count = 0, claimed_at NULL → immediately due). + sqlx::query( + "INSERT INTO outbox \ + (app_id, source_kind, trigger_id, script_id, payload, \ + origin_principal, trigger_depth) \ + VALUES ($1, 'cron', $2, $3, $4, $5, 0)", + ) + .bind(r.app_id) + .bind(r.id) + .bind(r.script_id) + .bind(payload) + .bind(r.registered_by_principal) + .execute(&mut *tx) + .await?; + + sqlx::query("UPDATE cron_trigger_details SET last_fired_at = $2 WHERE trigger_id = $1") + .bind(r.id) + .bind(now) + .execute(&mut *tx) + .await?; + + fired += 1; + } + + tx.commit().await?; + Ok(fired) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + #[test] + fn valid_six_field_schedule_accepted() { + // sec min hour dom mon dow — "every weekday at 9am". + validate_schedule("0 0 9 * * MON-FRI").unwrap(); + validate_schedule("*/5 * * * * *").unwrap(); + validate_schedule("0 0 0 1 1 *").unwrap(); + } + + #[test] + fn invalid_schedules_rejected() { + // 5-field (no seconds) is not the format we accept. + assert!(validate_schedule("* * * * *").is_err()); + // Gibberish. + assert!(validate_schedule("not a cron").is_err()); + assert!(validate_schedule("").is_err()); + // Out-of-range hour. + assert!(validate_schedule("0 0 99 * * *").is_err()); + } + + #[test] + fn known_timezones_accepted() { + validate_timezone("UTC").unwrap(); + validate_timezone("America/Los_Angeles").unwrap(); + validate_timezone("Europe/Berlin").unwrap(); + } + + #[test] + fn unknown_timezones_rejected() { + assert!(validate_timezone("Mars/Phobos").is_err()); + assert!(validate_timezone("PST").is_err()); // abbreviations aren't IANA names + assert!(validate_timezone("").is_err()); + } + + fn ts(s: &str) -> DateTime { + DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc) + } + + #[test] + fn due_when_next_slot_is_at_or_before_now() { + // Every minute at second 0. Last fired 90s ago → the next slot + // after that is due now. + let created = ts("2026-06-01T00:00:00Z"); + let last = Some(ts("2026-06-15T11:58:10Z")); + let now = ts("2026-06-15T12:00:05Z"); + let due = next_due("0 * * * * *", "UTC", last, created, now); + assert_eq!(due, Some(ts("2026-06-15T11:59:00Z"))); + } + + #[test] + fn not_due_when_next_slot_is_in_the_future() { + let created = ts("2026-06-01T00:00:00Z"); + let last = Some(ts("2026-06-15T12:00:00Z")); + let now = ts("2026-06-15T12:00:30Z"); + // Next minute slot is 12:01:00 — still in the future. + assert_eq!(next_due("0 * * * * *", "UTC", last, created, now), None); + } + + #[test] + fn never_fired_uses_created_at_as_reference() { + let created = ts("2026-06-15T12:00:10Z"); + let now = ts("2026-06-15T12:01:30Z"); + // First slot after creation is 12:01:00, which is <= now → due. + let due = next_due("0 * * * * *", "UTC", None, created, now); + assert_eq!(due, Some(ts("2026-06-15T12:01:00Z"))); + } + + /// Catch-up policy: a trigger that missed many windows fires exactly + /// ONCE. We simulate two consecutive scheduler ticks the way the DB + /// loop does — fire once, set last_fired = now, then re-evaluate. + #[test] + fn catch_up_fires_exactly_once_after_missed_windows() { + let created = ts("2026-06-15T09:00:00Z"); + // Last fired over 5 minutes (5 windows) ago. + let mut last_fired = Some(ts("2026-06-15T11:54:30Z")); + let now = ts("2026-06-15T12:00:05Z"); + + // Tick 1: due → fire once, advance last_fired to `now`. + let first = next_due("0 * * * * *", "UTC", last_fired, created, now); + assert!(first.is_some(), "should be due after missing windows"); + last_fired = Some(now); + + // Tick 2 (same wall-clock): NOT due again — only one fire total, + // not one-per-missed-window. + let second = next_due("0 * * * * *", "UTC", last_fired, created, now); + assert_eq!(second, None, "catch-up must fire exactly once"); + } + + #[test] + fn timezone_affects_fire_time() { + // "9am every day" in Los Angeles. On 2026-06-15, PDT = UTC-7, so + // 09:00 local = 16:00 UTC. + let created = ts("2026-06-15T00:00:00Z"); + let last = Some(ts("2026-06-15T15:59:00Z")); + let now = ts("2026-06-15T16:00:30Z"); + let due = next_due("0 0 9 * * *", "America/Los_Angeles", last, created, now); + assert_eq!(due, Some(ts("2026-06-15T16:00:00Z"))); + // Sanity: the same expression in UTC would NOT be due at 16:00. + assert_eq!(next_due("0 0 9 * * *", "UTC", last, created, now), None); + } + + #[test] + fn bad_schedule_or_tz_yields_none() { + let created = ts("2026-06-15T00:00:00Z"); + let now = ts("2026-06-15T12:00:00Z"); + assert_eq!(next_due("garbage", "UTC", None, created, now), None); + assert_eq!( + next_due("0 * * * * *", "Mars/Phobos", None, created, now), + None + ); + } + + #[test] + fn utc_offset_constructor_smoke() { + // Guard the chrono TimeZone import is actually exercised. + let dt = Utc.with_ymd_and_hms(2026, 6, 15, 12, 0, 0).unwrap(); + assert_eq!(dt, ts("2026-06-15T12:00:00Z")); + } +} diff --git a/crates/manager-core/src/dead_letters_api.rs b/crates/manager-core/src/dead_letters_api.rs index 30219f4..b78a04f 100644 --- a/crates/manager-core/src/dead_letters_api.rs +++ b/crates/manager-core/src/dead_letters_api.rs @@ -208,6 +208,9 @@ async fn resolve( fn admin_cx(app_id: AppId, principal: &Principal) -> SdkCallCx { SdkCallCx { app_id, + // Admin-plane cx (dead-letter replay/resolve) — no script is + // executing, so this attribution id is a fresh sentinel. + script_id: picloud_shared::ScriptId::new(), principal: Some(principal.clone()), execution_id: picloud_shared::ExecutionId::new(), request_id: picloud_shared::RequestId::new(), diff --git a/crates/manager-core/src/dispatcher.rs b/crates/manager-core/src/dispatcher.rs index 26d7f07..1f4e7b2 100644 --- a/crates/manager-core/src/dispatcher.rs +++ b/crates/manager-core/src/dispatcher.rs @@ -163,7 +163,10 @@ impl Dispatcher { return Ok(()); } }, - OutboxSourceKind::Kv | OutboxSourceKind::Docs | OutboxSourceKind::DeadLetter => { + OutboxSourceKind::Kv + | OutboxSourceKind::Docs + | OutboxSourceKind::DeadLetter + | OutboxSourceKind::Cron => { let resolved = self.resolve_trigger(&row).await?; let req = match self.build_exec_request(&row, &resolved).await { Ok(req) => req, diff --git a/crates/manager-core/src/docs_service.rs b/crates/manager-core/src/docs_service.rs index a15bd68..1a31eed 100644 --- a/crates/manager-core/src/docs_service.rs +++ b/crates/manager-core/src/docs_service.rs @@ -272,7 +272,7 @@ mod tests { use chrono::Utc; use picloud_shared::{ AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, NoopEventEmitter, Principal, - RequestId, UserId, + RequestId, ScriptId, UserId, }; use serde_json::json; use std::collections::BTreeMap; @@ -507,6 +507,7 @@ mod tests { fn anon_cx(app_id: AppId) -> SdkCallCx { SdkCallCx { app_id, + script_id: ScriptId::new(), principal: None, execution_id: ExecutionId::new(), request_id: RequestId::new(), @@ -520,6 +521,7 @@ mod tests { fn owner_cx(app_id: AppId) -> SdkCallCx { SdkCallCx { app_id, + script_id: ScriptId::new(), principal: Some(Principal { user_id: AdminUserId::new(), instance_role: InstanceRole::Owner, @@ -538,6 +540,7 @@ mod tests { fn member_no_role_cx(app_id: AppId) -> SdkCallCx { SdkCallCx { app_id, + script_id: ScriptId::new(), principal: Some(Principal { user_id: AdminUserId::new(), instance_role: InstanceRole::Member, diff --git a/crates/manager-core/src/http_service.rs b/crates/manager-core/src/http_service.rs new file mode 100644 index 0000000..b8fa1c8 --- /dev/null +++ b/crates/manager-core/src/http_service.rs @@ -0,0 +1,793 @@ +//! `HttpServiceImpl` — reqwest-backed outbound HTTP for the v1.1.4 +//! `http::*` SDK. +//! +//! Mirrors the v1.1.1+ stateful-service shape (`KvServiceImpl`): +//! script-as-gate authz (`AppHttpRequest`, skipped when +//! `cx.principal` is `None`), with the backend talking to the network +//! instead of Postgres. The reqwest client is built once at startup +//! with the [`crate::ssrf::SsrfResolver`] wired in via +//! `dns_resolver`, so the SSRF deny-list applies at every connection — +//! including each redirect hop, since redirects are followed manually +//! through the same client. +//! +//! Layering vs the raw client: +//! 1. URL validation: scheme must be http/https; ports 22/25/465/587 +//! are blocked. (IP-level filtering is the resolver's job.) +//! 2. Body-size caps on both request and response (stream-with-cap on +//! the response, checking `Content-Length` first). +//! 3. Total-request timeout (default 30s, max 60s) on top of the +//! client's 10s connect timeout. +//! 4. Default `User-Agent` unless the caller set one. +//! +//! Bodies/headers are never logged (PII): only url + status + duration +//! at debug level. + +use std::collections::BTreeMap; +use std::env; +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use picloud_shared::{HttpError, HttpRequest, HttpResponse, HttpService, SdkCallCx}; +use reqwest::header::{HeaderMap, HeaderName, HeaderValue, CONTENT_TYPE, LOCATION, USER_AGENT}; +use reqwest::{Client, Method, StatusCode}; + +use crate::authz::{self, AuthzRepo, Capability}; +use crate::ssrf::{self, SsrfPolicy, SSRF_BLOCK_PREFIX}; + +/// Default per-request timeout (ms) when the script omits `timeout_ms`. +pub const DEFAULT_TIMEOUT_MS: u32 = 30_000; +/// Hard ceiling on the per-request timeout. Values above this are +/// rejected by the bridge (not silently clamped). +pub const MAX_TIMEOUT_MS: u32 = 60_000; +/// Default redirect cap. +pub const DEFAULT_MAX_REDIRECTS: u32 = 5; +/// Hard ceiling on redirects. +pub const MAX_REDIRECTS_CEILING: u32 = 10; +/// 10 MB default body cap on both directions. +const DEFAULT_BODY_LIMIT_BYTES: usize = 10 * 1024 * 1024; +/// DNS + connect + TLS hard cap. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); + +/// Outbound-HTTP tunables. Env-overridable following the same pattern +/// as `TriggerConfig::from_env`. +#[derive(Debug, Clone, Copy)] +pub struct HttpConfig { + /// Disables the SSRF deny-list entirely. Dev/test only — the binary + /// logs a startup warning when this is set. + pub allow_private: bool, + pub max_request_body_bytes: usize, + pub max_response_body_bytes: usize, +} + +impl HttpConfig { + #[must_use] + pub const fn conservative() -> Self { + Self { + allow_private: false, + max_request_body_bytes: DEFAULT_BODY_LIMIT_BYTES, + max_response_body_bytes: DEFAULT_BODY_LIMIT_BYTES, + } + } + + #[must_use] + pub fn from_env() -> Self { + let mut c = Self::conservative(); + if let Ok(v) = env::var("PICLOUD_HTTP_ALLOW_PRIVATE") { + c.allow_private = + matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes"); + } + load_usize( + &mut c.max_request_body_bytes, + "PICLOUD_HTTP_MAX_REQUEST_BODY_BYTES", + ); + load_usize( + &mut c.max_response_body_bytes, + "PICLOUD_HTTP_MAX_RESPONSE_BODY_BYTES", + ); + c + } +} + +impl Default for HttpConfig { + fn default() -> Self { + Self::conservative() + } +} + +fn load_usize(dst: &mut usize, key: &str) { + if let Ok(v) = env::var(key) { + match v.parse::() { + Ok(n) => *dst = n, + Err(e) => { + tracing::warn!(env = key, error = %e, "ignoring invalid http-config value"); + } + } + } +} + +pub struct HttpServiceImpl { + client: Client, + authz: Arc, + config: HttpConfig, + /// Same policy wired into the DNS resolver. Held here too because + /// reqwest only routes *hostnames* through the custom resolver — a + /// URL with a **literal IP** host bypasses it, so literal IPs are + /// checked directly at URL-validation time. + policy: SsrfPolicy, +} + +impl HttpServiceImpl { + /// Build the service, constructing the reqwest client with the SSRF + /// resolver. Redirects are followed manually (so per-request limits + /// are honored and every hop re-resolves through the SSRF + /// resolver), hence `redirect(Policy::none())`. + /// + /// # Panics + /// + /// Panics if the reqwest client fails to build — this is a + /// startup-time invariant, not a runtime path. + #[must_use] + pub fn new(config: HttpConfig, authz: Arc) -> Self { + let policy = SsrfPolicy::new(config.allow_private); + let client = Client::builder() + .dns_resolver(ssrf::resolver(policy)) + .connect_timeout(CONNECT_TIMEOUT) + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("build outbound http client"); + Self { + client, + authz, + config, + policy, + } + } + + async fn check_request(&self, cx: &SdkCallCx) -> Result<(), HttpError> { + if let Some(ref principal) = cx.principal { + authz::require( + &*self.authz, + principal, + Capability::AppHttpRequest(cx.app_id), + ) + .await + .map_err(|_| HttpError::Forbidden)?; + } + Ok(()) + } +} + +#[async_trait] +impl HttpService for HttpServiceImpl { + async fn request(&self, cx: &SdkCallCx, req: HttpRequest) -> Result { + self.check_request(cx).await?; + + // Request body cap. + if let Some(ref body) = req.body { + if body.len() > self.config.max_request_body_bytes { + return Err(HttpError::BodyTooLarge("request")); + } + } + + let timeout = Duration::from_millis(u64::from(req.timeout_ms.min(MAX_TIMEOUT_MS))); + let started = std::time::Instant::now(); + let url_for_log = req.url.clone(); + + // Whole-request budget (DNS + connect + TLS + all redirect hops + // + body read). Connect alone is further bounded by the + // client's CONNECT_TIMEOUT. + let outcome = match tokio::time::timeout(timeout, self.run(req)).await { + Ok(r) => r, + Err(_) => Err(HttpError::Timeout), + }; + + let duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX); + match &outcome { + Ok(resp) => tracing::debug!( + url = %url_for_log, + status = resp.status, + duration_ms, + "outbound http" + ), + Err(err) => tracing::debug!( + url = %url_for_log, + error = %err, + duration_ms, + "outbound http failed" + ), + } + outcome + } +} + +impl HttpServiceImpl { + /// Core request path: validate, build headers, follow redirects + /// manually, read the response body with a cap. + async fn run(&self, req: HttpRequest) -> Result { + let method = Method::from_bytes(req.method.as_bytes()) + .map_err(|_| HttpError::Backend(format!("invalid method: {}", req.method)))?; + + let mut current = url::Url::parse(&req.url) + .map_err(|e| HttpError::InvalidUrl(format!("{}: {e}", req.url)))?; + validate_url(¤t, self.policy)?; + + let mut header_map = build_headers(&req, ¤t)?; + let mut method = method; + let mut body = req.body.clone(); + let mut redirects: u32 = 0; + let max_redirects = req.max_redirects.min(MAX_REDIRECTS_CEILING); + + loop { + // Re-validate scheme/port (and literal-IP SSRF) on each hop. + // Hostname IP filtering is the resolver's job and runs + // automatically at connect time. + validate_url(¤t, self.policy)?; + + let mut rb = self.client.request(method.clone(), current.clone()); + rb = rb.headers(header_map.clone()); + if let Some(ref b) = body { + rb = rb.body(b.clone()); + } + let resp = rb.send().await.map_err(map_reqwest_err)?; + let status = resp.status(); + + if req.follow_redirects && is_redirect(status) { + if let Some(loc) = resp.headers().get(LOCATION) { + if redirects >= max_redirects { + return Err(HttpError::Backend(format!( + "too many redirects (max {max_redirects})" + ))); + } + redirects += 1; + let loc_str = loc.to_str().map_err(|_| { + HttpError::Backend("redirect Location not valid UTF-8".into()) + })?; + current = current + .join(loc_str) + .map_err(|e| HttpError::InvalidUrl(format!("redirect target: {e}")))?; + + // 303 always → GET; 301/302 historically downgrade + // POST→GET (matches browsers). 307/308 preserve. + if matches!(status.as_u16(), 301..=303) { + method = Method::GET; + body = None; + header_map.remove(CONTENT_TYPE); + } + continue; + } + } + + return self.read_capped(resp).await; + } + } + + async fn read_capped(&self, resp: reqwest::Response) -> Result { + let status = resp.status().as_u16(); + let mut headers = BTreeMap::new(); + for (name, value) in resp.headers() { + // Header names lowercased per the documented response shape. + headers.insert( + name.as_str().to_ascii_lowercase(), + value.to_str().unwrap_or("").to_string(), + ); + } + + let cap = self.config.max_response_body_bytes; + if let Some(len) = resp.content_length() { + if len > cap as u64 { + return Err(HttpError::BodyTooLarge("response")); + } + } + + let mut buf: Vec = Vec::new(); + let mut resp = resp; + while let Some(chunk) = resp.chunk().await.map_err(map_reqwest_err)? { + if buf.len() + chunk.len() > cap { + return Err(HttpError::BodyTooLarge("response")); + } + buf.extend_from_slice(&chunk); + } + let body_raw = String::from_utf8_lossy(&buf).into_owned(); + Ok(HttpResponse { + status, + headers, + body_raw, + }) + } +} + +/// http/https only; block the SSH + SMTP ports; apply the SSRF policy +/// to **literal-IP** hosts (hostnames are filtered by the DNS resolver +/// at connect time, but literal IPs never reach the resolver). +fn validate_url(url: &url::Url, policy: SsrfPolicy) -> Result<(), HttpError> { + match url.scheme() { + "http" | "https" => {} + other => return Err(HttpError::BlockedScheme(other.to_string())), + } + match url.host() { + None => return Err(HttpError::InvalidUrl("missing host".into())), + Some(url::Host::Ipv4(ip)) => { + policy + .check(std::net::IpAddr::V4(ip)) + .map_err(|reason| HttpError::Ssrf(reason.to_string()))?; + } + Some(url::Host::Ipv6(ip)) => { + policy + .check(std::net::IpAddr::V6(ip)) + .map_err(|reason| HttpError::Ssrf(reason.to_string()))?; + } + Some(url::Host::Domain(_)) => {} + } + let port = url + .port_or_known_default() + .unwrap_or(if url.scheme() == "https" { 443 } else { 80 }); + if matches!(port, 22 | 25 | 465 | 587) { + return Err(HttpError::BlockedPort(port)); + } + Ok(()) +} + +/// Build the request header map: merge caller headers, then apply the +/// default `User-Agent` (unless overridden) and the bridge-chosen +/// `Content-Type` (unless overridden). +fn build_headers(req: &HttpRequest, _url: &url::Url) -> Result { + let mut map = HeaderMap::new(); + let mut has_user_agent = false; + let mut has_content_type = false; + for (k, v) in &req.headers { + let name = HeaderName::from_bytes(k.as_bytes()) + .map_err(|_| HttpError::Backend(format!("invalid header name: {k}")))?; + let value = HeaderValue::from_str(v) + .map_err(|_| HttpError::Backend(format!("invalid header value for {k}")))?; + if name == USER_AGENT { + has_user_agent = true; + } + if name == CONTENT_TYPE { + has_content_type = true; + } + map.append(name, value); + } + + if !has_user_agent { + let script = req.script_id.as_deref().unwrap_or("unknown"); + let ua = format!( + "picloud/{} (script:{})", + picloud_shared::PRODUCT_VERSION, + script + ); + if let Ok(value) = HeaderValue::from_str(&ua) { + map.insert(USER_AGENT, value); + } + } + + if !has_content_type { + if let Some(ref ct) = req.content_type { + if let Ok(value) = HeaderValue::from_str(ct) { + map.insert(CONTENT_TYPE, value); + } + } + } + + Ok(map) +} + +const fn is_redirect(status: StatusCode) -> bool { + matches!(status.as_u16(), 301..=303 | 307 | 308) +} + +/// Map a reqwest error to an `HttpError`, never leaking the resolved +/// IP. SSRF blocks are detected by scanning the error source chain for +/// the resolver's marker prefix. +fn map_reqwest_err(err: reqwest::Error) -> HttpError { + if let Some(reason) = ssrf_reason(&err) { + return HttpError::Ssrf(reason); + } + if err.is_timeout() { + return HttpError::Timeout; + } + if err.is_connect() { + return HttpError::Network("connection failed".into()); + } + if err.is_request() { + return HttpError::Network("request failed".into()); + } + HttpError::Network("network error".into()) +} + +/// Walk the error source chain looking for the SSRF marker the resolver +/// embeds. Returns the category reason (no IP) when found. +fn ssrf_reason(err: &reqwest::Error) -> Option { + let mut src: Option<&(dyn std::error::Error + 'static)> = Some(err); + while let Some(e) = src { + let s = e.to_string(); + if let Some(idx) = s.find(SSRF_BLOCK_PREFIX) { + return Some(s[idx + SSRF_BLOCK_PREFIX.len()..].to_string()); + } + src = e.source(); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::authz::AuthzError; + use async_trait::async_trait; + use picloud_shared::{ + AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, Principal, RequestId, ScriptId, + UserId, + }; + use std::collections::BTreeMap; + use std::io::Write as _; + use std::net::SocketAddr; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + struct AllowAuthz; + #[async_trait] + impl AuthzRepo for AllowAuthz { + async fn membership(&self, _u: UserId, _a: AppId) -> Result, AuthzError> { + Ok(Some(AppRole::Editor)) + } + } + struct DenyAuthz; + #[async_trait] + impl AuthzRepo for DenyAuthz { + async fn membership(&self, _u: UserId, _a: AppId) -> Result, AuthzError> { + Ok(None) + } + } + + fn dev_service(authz: Arc) -> HttpServiceImpl { + // allow_private so the test TcpListener on 127.0.0.1 is reachable. + let mut config = HttpConfig::conservative(); + config.allow_private = true; + HttpServiceImpl::new(config, authz) + } + + fn anon_cx() -> SdkCallCx { + SdkCallCx { + app_id: AppId::new(), + script_id: ScriptId::new(), + principal: None, + execution_id: ExecutionId::new(), + request_id: RequestId::new(), + trigger_depth: 0, + root_execution_id: ExecutionId::new(), + is_dead_letter_handler: false, + event: None, + } + } + + fn member_cx() -> SdkCallCx { + let mut cx = anon_cx(); + cx.principal = Some(Principal { + user_id: AdminUserId::new(), + instance_role: InstanceRole::Member, + scopes: None, + app_binding: None, + }); + cx + } + + fn req(method: &str, url: String) -> HttpRequest { + HttpRequest { + method: method.into(), + url, + headers: BTreeMap::new(), + body: None, + content_type: None, + timeout_ms: 5000, + follow_redirects: true, + max_redirects: 5, + script_id: Some("test-script".into()), + } + } + + /// Minimal single-shot HTTP/1.1 server. Reads the request, runs + /// `handler` to produce the raw response bytes, writes them, closes. + /// Returns the bound address. + async fn spawn_server(handler: F) -> SocketAddr + where + F: Fn(String) -> Vec + Send + Sync + 'static, + { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + loop { + let Ok((mut sock, _)) = listener.accept().await else { + break; + }; + let mut buf = vec![0u8; 65536]; + let n = sock.read(&mut buf).await.unwrap_or(0); + let request = String::from_utf8_lossy(&buf[..n]).to_string(); + let response = handler(request); + let _ = sock.write_all(&response).await; + let _ = sock.flush().await; + } + }); + addr + } + + fn ok_response(body: &str, content_type: &str) -> Vec { + let mut v = Vec::new(); + write!( + v, + "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .unwrap(); + v + } + + #[tokio::test] + async fn get_round_trip() { + let addr = spawn_server(|_req| ok_response("hello", "text/plain")).await; + let svc = dev_service(Arc::new(AllowAuthz)); + let resp = svc + .request(&anon_cx(), req("GET", format!("http://{addr}/"))) + .await + .unwrap(); + assert_eq!(resp.status, 200); + assert_eq!(resp.body_raw, "hello"); + assert_eq!( + resp.headers.get("content-type").map(String::as_str), + Some("text/plain") + ); + } + + #[tokio::test] + async fn post_sends_body_and_default_user_agent() { + let addr = spawn_server(|request| { + // Echo back whether the body + default UA were present. + let has_ua = request.to_lowercase().contains("user-agent: picloud/"); + let has_body = request.contains("xyzzy"); + ok_response(&format!("ua={has_ua},body={has_body}"), "text/plain") + }) + .await; + let svc = dev_service(Arc::new(AllowAuthz)); + let mut r = req("POST", format!("http://{addr}/")); + r.body = Some(b"xyzzy".to_vec()); + r.content_type = Some("text/plain".into()); + let resp = svc.request(&anon_cx(), r).await.unwrap(); + assert_eq!(resp.body_raw, "ua=true,body=true"); + } + + #[tokio::test] + async fn custom_user_agent_overrides_default() { + let addr = spawn_server(|request| { + let has_custom = request.to_lowercase().contains("user-agent: my-agent"); + let has_default = request.to_lowercase().contains("picloud/"); + ok_response( + &format!("custom={has_custom},default={has_default}"), + "text/plain", + ) + }) + .await; + let svc = dev_service(Arc::new(AllowAuthz)); + let mut r = req("GET", format!("http://{addr}/")); + r.headers.insert("User-Agent".into(), "my-agent".into()); + let resp = svc.request(&anon_cx(), r).await.unwrap(); + assert_eq!(resp.body_raw, "custom=true,default=false"); + } + + #[tokio::test] + async fn empty_body_response() { + let addr = spawn_server(|_r| { + b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_vec() + }) + .await; + let svc = dev_service(Arc::new(AllowAuthz)); + let resp = svc + .request(&anon_cx(), req("GET", format!("http://{addr}/"))) + .await + .unwrap(); + assert_eq!(resp.status, 204); + assert_eq!(resp.body_raw, ""); + } + + #[tokio::test] + async fn non_2xx_does_not_error() { + let addr = spawn_server(|_r| { + b"HTTP/1.1 500 Internal Server Error\r\nContent-Length: 3\r\nConnection: close\r\n\r\nerr".to_vec() + }) + .await; + let svc = dev_service(Arc::new(AllowAuthz)); + let resp = svc + .request(&anon_cx(), req("GET", format!("http://{addr}/"))) + .await + .unwrap(); + assert_eq!(resp.status, 500); + assert_eq!(resp.body_raw, "err"); + } + + #[tokio::test] + async fn response_over_content_length_cap_rejected() { + let addr = spawn_server(|_r| ok_response("0123456789", "text/plain")).await; + let mut config = HttpConfig::conservative(); + config.allow_private = true; + config.max_response_body_bytes = 5; // body is 10 bytes + let svc = HttpServiceImpl::new(config, Arc::new(AllowAuthz)); + let err = svc + .request(&anon_cx(), req("GET", format!("http://{addr}/"))) + .await + .unwrap_err(); + assert!(matches!(err, HttpError::BodyTooLarge("response"))); + } + + #[tokio::test] + async fn response_over_cap_without_content_length_rejected_mid_stream() { + // No Content-Length header → must be caught while streaming. + let addr = spawn_server(|_r| { + b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n0123456789ABCDEF".to_vec() + }) + .await; + let mut config = HttpConfig::conservative(); + config.allow_private = true; + config.max_response_body_bytes = 4; + let svc = HttpServiceImpl::new(config, Arc::new(AllowAuthz)); + let err = svc + .request(&anon_cx(), req("GET", format!("http://{addr}/"))) + .await + .unwrap_err(); + assert!(matches!(err, HttpError::BodyTooLarge("response"))); + } + + #[tokio::test] + async fn request_body_over_cap_rejected_before_send() { + let mut config = HttpConfig::conservative(); + config.allow_private = true; + config.max_request_body_bytes = 3; + let svc = HttpServiceImpl::new(config, Arc::new(AllowAuthz)); + let mut r = req("POST", "http://127.0.0.1:1/".into()); + r.body = Some(b"too long".to_vec()); + let err = svc.request(&anon_cx(), r).await.unwrap_err(); + assert!(matches!(err, HttpError::BodyTooLarge("request"))); + } + + #[tokio::test] + async fn redirect_followed_up_to_then_throws_beyond_max() { + // Server always 302s to itself → unbounded redirect loop, + // bounded by max_redirects. + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + loop { + let Ok((mut sock, _)) = listener.accept().await else { + break; + }; + let mut buf = vec![0u8; 4096]; + let _ = sock.read(&mut buf).await; + let body = format!( + "HTTP/1.1 302 Found\r\nLocation: http://{addr}/next\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ); + let _ = sock.write_all(body.as_bytes()).await; + } + }); + let svc = dev_service(Arc::new(AllowAuthz)); + let mut r = req("GET", format!("http://{addr}/")); + r.max_redirects = 2; + let err = svc.request(&anon_cx(), r).await.unwrap_err(); + assert!( + matches!(err, HttpError::Backend(ref m) if m.contains("too many redirects")), + "expected too-many-redirects, got {err:?}" + ); + } + + #[tokio::test] + async fn scheme_rejected() { + let svc = dev_service(Arc::new(AllowAuthz)); + for url in ["file:///etc/passwd", "ftp://host/x", "gopher://host/"] { + let err = svc + .request(&anon_cx(), req("GET", url.into())) + .await + .unwrap_err(); + match err { + HttpError::BlockedScheme(s) => { + assert!(url.starts_with(&s), "scheme {s} not in url {url}"); + } + other => panic!("expected BlockedScheme for {url}, got {other:?}"), + } + } + } + + #[tokio::test] + async fn ports_rejected() { + let svc = dev_service(Arc::new(AllowAuthz)); + for port in [22u16, 25, 465, 587] { + let err = svc + .request( + &anon_cx(), + req("GET", format!("http://example.com:{port}/")), + ) + .await + .unwrap_err(); + assert!( + matches!(err, HttpError::BlockedPort(p) if p == port), + "port {port} should be blocked, got {err:?}" + ); + } + } + + #[tokio::test] + async fn ssrf_blocks_loopback_without_allow_private() { + // Default config (deny-list ON). A request to a loopback host + // must surface as Ssrf, not a generic network error. + let svc = HttpServiceImpl::new(HttpConfig::conservative(), Arc::new(AllowAuthz)); + let err = svc + .request(&anon_cx(), req("GET", "http://127.0.0.1:9/".into())) + .await + .unwrap_err(); + match err { + HttpError::Ssrf(reason) => { + assert_eq!(reason, "loopback"); + assert!(!reason.contains("127.0.0.1"), "reason must not leak the IP"); + } + other => panic!("expected Ssrf, got {other:?}"), + } + } + + #[tokio::test] + async fn ssrf_blocks_hostname_resolving_to_loopback() { + // `localhost` resolves to 127.0.0.1 / ::1 — all denied. This + // exercises the DNS-resolver path (vs the literal-IP path) and + // must surface as Ssrf, not a generic DNS error. + let svc = HttpServiceImpl::new(HttpConfig::conservative(), Arc::new(AllowAuthz)); + let err = svc + .request(&anon_cx(), req("GET", "http://localhost:9/".into())) + .await + .unwrap_err(); + assert!( + matches!(err, HttpError::Ssrf(_)), + "expected Ssrf for localhost, got {err:?}" + ); + } + + #[tokio::test] + async fn timeout_throws() { + // Server that accepts then never responds. + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + if let Ok((sock, _)) = listener.accept().await { + // Hold the socket open without replying. + tokio::time::sleep(Duration::from_secs(30)).await; + drop(sock); + } + }); + let svc = dev_service(Arc::new(AllowAuthz)); + let mut r = req("GET", format!("http://{addr}/")); + r.timeout_ms = 300; + let err = svc.request(&anon_cx(), r).await.unwrap_err(); + assert!(matches!(err, HttpError::Timeout), "got {err:?}"); + } + + #[tokio::test] + async fn anon_skips_authz_member_without_scope_forbidden() { + let addr = spawn_server(|_r| ok_response("ok", "text/plain")).await; + // Anonymous principal → authz skipped even with DenyAuthz. + let svc = dev_service(Arc::new(DenyAuthz)); + let ok = svc + .request(&anon_cx(), req("GET", format!("http://{addr}/"))) + .await; + assert!(ok.is_ok()); + // Authenticated member with no role → Forbidden. + let err = svc + .request(&member_cx(), req("GET", format!("http://{addr}/"))) + .await + .unwrap_err(); + assert!(matches!(err, HttpError::Forbidden)); + } + + #[tokio::test] + async fn member_with_role_allowed() { + let addr = spawn_server(|_r| ok_response("ok", "text/plain")).await; + let svc = dev_service(Arc::new(AllowAuthz)); + let resp = svc + .request(&member_cx(), req("GET", format!("http://{addr}/"))) + .await + .unwrap(); + assert_eq!(resp.status, 200); + } +} diff --git a/crates/manager-core/src/kv_service.rs b/crates/manager-core/src/kv_service.rs index 881a86c..d38dc67 100644 --- a/crates/manager-core/src/kv_service.rs +++ b/crates/manager-core/src/kv_service.rs @@ -188,7 +188,7 @@ mod tests { use async_trait::async_trait; use picloud_shared::{ AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, NoopEventEmitter, Principal, - RequestId, UserId, + RequestId, ScriptId, UserId, }; use std::collections::{BTreeMap, HashMap}; use tokio::sync::Mutex; @@ -301,6 +301,7 @@ mod tests { fn anon_cx(app_id: AppId) -> SdkCallCx { SdkCallCx { app_id, + script_id: ScriptId::new(), principal: None, execution_id: ExecutionId::new(), request_id: RequestId::new(), @@ -314,6 +315,7 @@ mod tests { fn owner_cx(app_id: AppId) -> SdkCallCx { SdkCallCx { app_id, + script_id: ScriptId::new(), principal: Some(Principal { user_id: AdminUserId::new(), instance_role: InstanceRole::Owner, @@ -332,6 +334,7 @@ mod tests { fn member_no_role_cx(app_id: AppId) -> SdkCallCx { SdkCallCx { app_id, + script_id: ScriptId::new(), principal: Some(Principal { user_id: AdminUserId::new(), instance_role: InstanceRole::Member, diff --git a/crates/manager-core/src/lib.rs b/crates/manager-core/src/lib.rs index 4da2cd8..ba059a1 100644 --- a/crates/manager-core/src/lib.rs +++ b/crates/manager-core/src/lib.rs @@ -22,6 +22,7 @@ pub mod auth_api; pub mod auth_bootstrap; pub mod auth_middleware; pub mod authz; +pub mod cron_scheduler; pub mod dead_letter_repo; pub mod dead_letter_service; pub mod dead_letters_api; @@ -30,6 +31,7 @@ pub mod docs_filter; pub mod docs_repo; pub mod docs_service; pub mod gc; +pub mod http_service; pub mod kv_repo; pub mod kv_service; pub mod log_sink; @@ -43,6 +45,7 @@ pub mod route_admin; pub mod route_repo; pub mod sandbox; pub mod scheduler; +pub mod ssrf; pub mod trigger_config; pub mod trigger_repo; pub mod triggers_api; @@ -84,6 +87,7 @@ pub use auth_middleware::{ API_KEY_PREFIX, API_KEY_PREFIX_LEN, SESSION_COOKIE, }; pub use authz::{can, require, AuthzDenied, AuthzError, AuthzRepo, Capability, Decision}; +pub use cron_scheduler::spawn_cron_scheduler; pub use dead_letter_repo::{ DeadLetterRepo, DeadLetterRepoError, DeadLetterRow, NewDeadLetter, PostgresDeadLetterRepo, }; @@ -93,6 +97,7 @@ pub use dispatcher::{compute_backoff, Dispatcher, DispatcherError}; pub use docs_repo::{DocsRepo, DocsRepoError, PostgresDocsRepo}; pub use docs_service::DocsServiceImpl; pub use gc::{spawn_abandoned_gc, spawn_dead_letter_gc}; +pub use http_service::{HttpConfig, HttpServiceImpl}; pub use kv_repo::{KvRepo, KvRepoError, PostgresKvRepo}; pub use kv_service::KvServiceImpl; pub use log_sink::PostgresExecutionLogSink; diff --git a/crates/manager-core/src/outbox_repo.rs b/crates/manager-core/src/outbox_repo.rs index 996b50d..4bb41fa 100644 --- a/crates/manager-core/src/outbox_repo.rs +++ b/crates/manager-core/src/outbox_repo.rs @@ -25,6 +25,8 @@ pub enum OutboxSourceKind { /// v1.1.2. Docs, DeadLetter, + /// v1.1.4. + Cron, } impl OutboxSourceKind { @@ -35,6 +37,7 @@ impl OutboxSourceKind { Self::Kv => "kv", Self::Docs => "docs", Self::DeadLetter => "dead_letter", + Self::Cron => "cron", } } @@ -45,6 +48,7 @@ impl OutboxSourceKind { "kv" => Some(Self::Kv), "docs" => Some(Self::Docs), "dead_letter" => Some(Self::DeadLetter), + "cron" => Some(Self::Cron), _ => None, } } diff --git a/crates/manager-core/src/ssrf.rs b/crates/manager-core/src/ssrf.rs new file mode 100644 index 0000000..2e2e653 --- /dev/null +++ b/crates/manager-core/src/ssrf.rs @@ -0,0 +1,457 @@ +//! SSRF deny-list — the load-bearing security mechanism behind the +//! v1.1.4 `http::*` SDK. +//! +//! The policy is applied to the **resolved IP address**, not the +//! hostname. That is the DNS-rebinding defense: a hostname that +//! resolves to a public IP at lookup time and a private IP at connect +//! time is not exploitable, because reqwest re-runs every connection +//! (including post-redirect hops) through [`SsrfResolver`], which +//! filters the address list before the socket is opened. +//! +//! [`SsrfPolicy::check`] returns a CIDR-*category* reason on denial +//! (e.g. `"loopback"`, `"private"`) — never the IP itself, so the +//! script-visible error can't be used to map the internal network. +//! +//! `PICLOUD_HTTP_ALLOW_PRIVATE=true` flips `allow_private`, which +//! short-circuits every check to allow. That is dev/test-only and the +//! binary logs a startup warning when it's set. + +use std::future::Future; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; +use std::pin::Pin; +use std::sync::Arc; + +use reqwest::dns::{Addrs, Name, Resolve, Resolving}; + +/// Decision policy for a single resolved IP. Cheap to clone (one bool). +#[derive(Debug, Clone, Copy)] +pub struct SsrfPolicy { + /// When true, every address is allowed — the entire deny-list is + /// disabled. Set from `PICLOUD_HTTP_ALLOW_PRIVATE`. Dev/test only. + pub allow_private: bool, +} + +impl SsrfPolicy { + #[must_use] + pub const fn new(allow_private: bool) -> Self { + Self { allow_private } + } + + /// `Ok(())` if the IP may be connected to; `Err(reason)` with a + /// CIDR-category label otherwise. The reason is safe to surface to + /// a script — it never contains the address. + /// + /// # Errors + /// + /// Returns the deny reason when `ip` falls in a blocked range and + /// `allow_private` is false. + pub fn check(&self, ip: IpAddr) -> Result<(), &'static str> { + if self.allow_private { + return Ok(()); + } + match ip { + IpAddr::V4(v4) => check_v4(v4), + IpAddr::V6(v6) => check_v6(v6), + } + } + + #[must_use] + pub fn is_allowed(&self, ip: IpAddr) -> bool { + self.check(ip).is_ok() + } +} + +/// IPv4 deny-list. Order doesn't matter (ranges are disjoint by +/// construction); first match wins for the reason label. +// Several arms share a reason ("private") for distinct CIDRs — keeping +// them separate documents each blocked range explicitly. +#[allow(clippy::match_same_arms)] +fn check_v4(ip: Ipv4Addr) -> Result<(), &'static str> { + let o = ip.octets(); + match o { + [127, ..] => Err("loopback"), + [0, ..] => Err("unspecified"), // 0.0.0.0/8 "this network" + [10, ..] => Err("private"), + [172, b, ..] if (16..=31).contains(&b) => Err("private"), + [192, 168, ..] => Err("private"), + [169, 254, ..] => Err("link-local"), // includes cloud metadata 169.254.169.254 + [100, b, ..] if (64..=127).contains(&b) => Err("carrier-grade-nat"), + [224..=239, ..] => Err("multicast"), + [240..=255, ..] => Err("reserved"), + _ => Ok(()), + } +} + +/// IPv6 deny-list. IPv4-mapped addresses (`::ffff:0:0/96`) re-run the +/// v4 deny-list against the embedded address. +fn check_v6(ip: Ipv6Addr) -> Result<(), &'static str> { + // IPv4-mapped (::ffff:a.b.c.d) — re-check the embedded v4 address + // so a mapped private/loopback address can't sneak through. + if let Some(v4) = ip.to_ipv4_mapped() { + return check_v4(v4); + } + if ip == Ipv6Addr::LOCALHOST { + return Err("loopback"); + } + if ip == Ipv6Addr::UNSPECIFIED { + return Err("unspecified"); + } + let seg0 = ip.segments()[0]; + if seg0 & 0xffc0 == 0xfe80 { + return Err("link-local"); // fe80::/10 + } + if seg0 & 0xfe00 == 0xfc00 { + return Err("unique-local"); // fc00::/7 + } + if seg0 & 0xff00 == 0xff00 { + return Err("multicast"); // ff00::/8 + } + Ok(()) +} + +/// Marker error returned by the resolver when *every* resolved address +/// for a host was denied. reqwest wraps this into a connect error; the +/// `http_service` impl walks the source chain for the +/// `"blocked by SSRF policy:"` prefix to surface a clean +/// [`crate::http_service::HttpError::Ssrf`] instead of a generic DNS +/// failure. Keeping the reason a category label means no IP leaks. +#[derive(Debug)] +struct SsrfBlocked { + reason: &'static str, +} + +impl std::fmt::Display for SsrfBlocked { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "blocked by SSRF policy: {}", self.reason) + } +} + +impl std::error::Error for SsrfBlocked {} + +/// Prefix the resolver embeds in its error and the impl scans for. +pub const SSRF_BLOCK_PREFIX: &str = "blocked by SSRF policy: "; + +/// Pluggable host→addresses lookup. Production uses the system +/// resolver; tests inject a closure (e.g. to simulate DNS rebinding — +/// a different address on a later call). +pub type LookupFn = Arc< + dyn Fn(String) -> Pin>> + Send>> + + Send + + Sync, +>; + +fn system_lookup( + host: String, +) -> Pin>> + Send>> { + Box::pin(async move { + // Port 0 — reqwest overrides it with the real target port. + Ok(tokio::net::lookup_host((host.as_str(), 0u16)) + .await? + .collect()) + }) +} + +/// reqwest DNS resolver that delegates to the system resolver, then +/// filters the address list through [`SsrfPolicy`]. Plugged in via +/// `ClientBuilder::dns_resolver`, so it runs at the actual connection +/// point — including for every redirect hop. This is the DNS-rebinding +/// defense: filtering happens at connect time, not at URL-parse time. +#[derive(Clone)] +pub struct SsrfResolver { + policy: SsrfPolicy, + lookup: LookupFn, +} + +impl SsrfResolver { + #[must_use] + pub fn new(policy: SsrfPolicy) -> Self { + Self { + policy, + lookup: Arc::new(system_lookup), + } + } + + /// Construct with an injected lookup (tests only). + #[must_use] + pub fn with_lookup(policy: SsrfPolicy, lookup: LookupFn) -> Self { + Self { policy, lookup } + } +} + +impl Resolve for SsrfResolver { + fn resolve(&self, name: Name) -> Resolving { + let policy = self.policy; + let lookup = self.lookup.clone(); + let host = name.as_str().to_string(); + Box::pin(async move { + let resolved: Vec = lookup(host) + .await + .map_err(|e| -> Box { Box::new(e) })?; + + // Empty resolution → genuine DNS miss; let reqwest surface + // it as a normal "no addresses" error. + if resolved.is_empty() { + let addrs: Addrs = Box::new(std::iter::empty()); + return Ok(addrs); + } + + let mut allowed: Vec = Vec::with_capacity(resolved.len()); + let mut last_reason: &'static str = "denied"; + for sa in resolved { + match policy.check(sa.ip()) { + Ok(()) => allowed.push(sa), + Err(reason) => last_reason = reason, + } + } + + // Resolution returned addresses but the policy denied them + // all → fail with the SSRF marker so the impl can report a + // policy block (not a generic DNS error). + if allowed.is_empty() { + let err: Box = Box::new(SsrfBlocked { + reason: last_reason, + }); + return Err(err); + } + + let addrs: Addrs = Box::new(allowed.into_iter()); + Ok(addrs) + }) + } +} + +/// Build the resolver. reqwest's `dns_resolver` is generic over a +/// concrete `R: Resolve` (it stores `Arc`), so this returns the +/// concrete `Arc` rather than a trait object. +#[must_use] +pub fn resolver(policy: SsrfPolicy) -> Arc { + Arc::new(SsrfResolver::new(policy)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + + fn denied(ip: &str) -> &'static str { + let policy = SsrfPolicy::new(false); + policy + .check(IpAddr::from_str(ip).unwrap()) + .expect_err(&format!("{ip} should be denied")) + } + + fn allowed(ip: &str) { + let policy = SsrfPolicy::new(false); + policy + .check(IpAddr::from_str(ip).unwrap()) + .unwrap_or_else(|r| panic!("{ip} should be allowed, denied as {r}")); + } + + #[test] + fn denies_ipv4_loopback() { + assert_eq!(denied("127.0.0.1"), "loopback"); + assert_eq!(denied("127.1.2.3"), "loopback"); + } + + #[test] + fn denies_ipv4_unspecified() { + assert_eq!(denied("0.0.0.0"), "unspecified"); + } + + #[test] + fn denies_rfc1918_private() { + assert_eq!(denied("10.0.0.1"), "private"); + assert_eq!(denied("10.255.255.255"), "private"); + assert_eq!(denied("172.16.0.1"), "private"); + assert_eq!(denied("172.31.255.255"), "private"); + assert_eq!(denied("192.168.0.1"), "private"); + } + + #[test] + fn allows_172_outside_private_range() { + // 172.15.x and 172.32.x are public — only 172.16.0.0/12 is private. + allowed("172.15.0.1"); + allowed("172.32.0.1"); + } + + #[test] + fn denies_link_local_and_cloud_metadata() { + assert_eq!(denied("169.254.0.1"), "link-local"); + // The cloud metadata endpoint is the canonical SSRF target. + assert_eq!(denied("169.254.169.254"), "link-local"); + } + + #[test] + fn denies_carrier_grade_nat() { + assert_eq!(denied("100.64.0.1"), "carrier-grade-nat"); + assert_eq!(denied("100.127.255.255"), "carrier-grade-nat"); + // 100.63.x and 100.128.x are outside 100.64.0.0/10. + allowed("100.63.0.1"); + allowed("100.128.0.1"); + } + + #[test] + fn denies_multicast_and_reserved() { + assert_eq!(denied("224.0.0.1"), "multicast"); + assert_eq!(denied("239.255.255.255"), "multicast"); + assert_eq!(denied("240.0.0.1"), "reserved"); + assert_eq!(denied("255.255.255.255"), "reserved"); + } + + #[test] + fn allows_public_ipv4() { + allowed("1.1.1.1"); + allowed("8.8.8.8"); + allowed("93.184.216.34"); // example.com + } + + #[test] + fn denies_ipv6_loopback() { + assert_eq!(denied("::1"), "loopback"); + } + + #[test] + fn denies_ipv6_unspecified() { + assert_eq!(denied("::"), "unspecified"); + } + + #[test] + fn denies_ipv6_link_local() { + assert_eq!(denied("fe80::1"), "link-local"); + assert_eq!(denied("febf:ffff::1"), "link-local"); + } + + #[test] + fn denies_ipv6_unique_local() { + assert_eq!(denied("fc00::1"), "unique-local"); + assert_eq!(denied("fd12:3456::1"), "unique-local"); + } + + #[test] + fn denies_ipv6_multicast() { + assert_eq!(denied("ff00::1"), "multicast"); + assert_eq!(denied("ff02::1"), "multicast"); + } + + #[test] + fn allows_public_ipv6() { + allowed("2606:4700:4700::1111"); // cloudflare + allowed("2001:4860:4860::8888"); // google + } + + #[test] + fn ipv4_mapped_ipv6_rechecks_embedded_address() { + // ::ffff:127.0.0.1 must be denied via the embedded-v4 re-check. + assert_eq!(denied("::ffff:127.0.0.1"), "loopback"); + assert_eq!(denied("::ffff:10.0.0.1"), "private"); + assert_eq!(denied("::ffff:169.254.169.254"), "link-local"); + // A mapped *public* address stays allowed. + allowed("::ffff:1.1.1.1"); + } + + #[test] + fn allow_private_disables_all_denials() { + let policy = SsrfPolicy::new(true); + for ip in ["127.0.0.1", "10.0.0.1", "169.254.169.254", "::1", "fe80::1"] { + assert!(policy.is_allowed(IpAddr::from_str(ip).unwrap())); + } + } + + // --- resolver-path tests (the connect-time filter) --- + + use std::sync::atomic::{AtomicUsize, Ordering}; + + fn name(s: &str) -> Name { + Name::from_str(s).unwrap() + } + + fn fixed_lookup(addrs: Vec) -> LookupFn { + Arc::new(move |_host| { + let addrs = addrs.clone(); + Box::pin(async move { Ok(addrs) }) + }) + } + + #[tokio::test] + async fn resolver_returns_only_allowed_addresses() { + // A host resolving to one public + one private IP yields only + // the public one to reqwest. + let public: SocketAddr = "1.1.1.1:0".parse().unwrap(); + let private: SocketAddr = "10.0.0.1:0".parse().unwrap(); + let resolver = + SsrfResolver::with_lookup(SsrfPolicy::new(false), fixed_lookup(vec![public, private])); + let got: Vec = resolver + .resolve(name("mixed.example")) + .await + .unwrap() + .collect(); + assert_eq!(got, vec![public]); + } + + #[tokio::test] + async fn resolver_all_denied_fails_with_ssrf_marker() { + // A host resolving to ONLY private IPs fails with the SSRF + // marker (not a generic empty/DNS result). + let resolver = SsrfResolver::with_lookup( + SsrfPolicy::new(false), + fixed_lookup(vec![ + "10.0.0.1:0".parse().unwrap(), + "127.0.0.1:0".parse().unwrap(), + ]), + ); + let Err(err) = resolver.resolve(name("internal.example")).await else { + panic!("all-denied resolution should error"); + }; + assert!( + err.to_string().starts_with(SSRF_BLOCK_PREFIX), + "expected SSRF marker, got: {err}" + ); + } + + #[tokio::test] + async fn resolver_dns_rebinding_second_resolution_denied() { + // Simulate rebinding: public IP on the first lookup, private on + // the second. The connect-time filter denies the second. + let calls = Arc::new(AtomicUsize::new(0)); + let calls2 = calls.clone(); + let lookup: LookupFn = Arc::new(move |_host| { + let n = calls2.fetch_add(1, Ordering::SeqCst); + Box::pin(async move { + let addr: SocketAddr = if n == 0 { + "1.1.1.1:0".parse().unwrap() + } else { + "127.0.0.1:0".parse().unwrap() + }; + Ok(vec![addr]) + }) + }); + let resolver = SsrfResolver::with_lookup(SsrfPolicy::new(false), lookup); + + // First resolution: public → allowed. + let first: Vec = resolver + .resolve(name("rebind.example")) + .await + .unwrap() + .collect(); + assert_eq!(first, vec!["1.1.1.1:0".parse::().unwrap()]); + + // Second resolution: rebinding returns loopback → denied. + let Err(err) = resolver.resolve(name("rebind.example")).await else { + panic!("rebound private address must be denied"); + }; + assert!(err.to_string().contains("loopback")); + } + + #[tokio::test] + async fn resolver_empty_resolution_is_not_ssrf() { + // Genuine DNS miss (no addresses) returns an empty iterator, + // NOT the SSRF marker — reqwest surfaces a normal DNS error. + let resolver = SsrfResolver::with_lookup(SsrfPolicy::new(false), fixed_lookup(vec![])); + let got: Vec = resolver + .resolve(name("nxdomain.example")) + .await + .unwrap() + .collect(); + assert!(got.is_empty()); + } +} diff --git a/crates/manager-core/src/trigger_config.rs b/crates/manager-core/src/trigger_config.rs index 71ddab2..67b192a 100644 --- a/crates/manager-core/src/trigger_config.rs +++ b/crates/manager-core/src/trigger_config.rs @@ -56,6 +56,11 @@ pub struct TriggerConfig { pub dead_letter_retention_days: u32, /// abandoned-execution retention before GC, in days. Default 7. pub abandoned_retention_days: u32, + + /// Cron scheduler poll cadence, in ms (v1.1.4). Default 30 000 — + /// real-world cron precision is per-minute, so a 30s tick is fine. + /// Floored at 1s by the scheduler. + pub cron_tick_interval_ms: u32, } impl TriggerConfig { @@ -69,6 +74,7 @@ impl TriggerConfig { retry_jitter_pct: 20, dead_letter_retention_days: 30, abandoned_retention_days: 7, + cron_tick_interval_ms: 30_000, } } @@ -91,6 +97,10 @@ impl TriggerConfig { &mut c.abandoned_retention_days, "PICLOUD_ABANDONED_EXECUTIONS_RETENTION_DAYS", ); + load_u32( + &mut c.cron_tick_interval_ms, + "PICLOUD_CRON_TICK_INTERVAL_MS", + ); c } } @@ -141,6 +151,7 @@ mod tests { assert_eq!(c.retry_jitter_pct, 20); assert_eq!(c.dead_letter_retention_days, 30); assert_eq!(c.abandoned_retention_days, 7); + assert_eq!(c.cron_tick_interval_ms, 30_000); } #[test] diff --git a/crates/manager-core/src/trigger_repo.rs b/crates/manager-core/src/trigger_repo.rs index c2aad45..1f5111b 100644 --- a/crates/manager-core/src/trigger_repo.rs +++ b/crates/manager-core/src/trigger_repo.rs @@ -49,6 +49,8 @@ pub enum TriggerKind { Kv, Docs, DeadLetter, + /// v1.1.4. + Cron, } impl TriggerKind { @@ -58,6 +60,7 @@ impl TriggerKind { Self::Kv => "kv", Self::Docs => "docs", Self::DeadLetter => "dead_letter", + Self::Cron => "cron", } } @@ -67,6 +70,7 @@ impl TriggerKind { "kv" => Some(Self::Kv), "docs" => Some(Self::Docs), "dead_letter" => Some(Self::DeadLetter), + "cron" => Some(Self::Cron), _ => None, } } @@ -108,6 +112,14 @@ pub enum TriggerDetails { #[serde(default, skip_serializing_if = "Option::is_none")] script_id_filter: Option, }, + /// v1.1.4. The 6-field cron schedule + IANA timezone the trigger + /// fires on, plus the last enqueue time (for dashboard display). + Cron { + schedule: String, + timezone: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + last_fired_at: Option>, + }, } /// Create payload for a KV trigger. Defaults applied at the admin @@ -148,6 +160,21 @@ pub struct CreateDeadLetterTrigger { pub registered_by_principal: AdminUserId, } +/// Create payload for a cron trigger (v1.1.4). `schedule` is a 6-field +/// cron expression and `timezone` an IANA name; both are validated +/// (by the admin endpoint and defensively by the repo) before insert. +#[derive(Debug, Clone)] +pub struct CreateCronTrigger { + pub script_id: ScriptId, + pub schedule: String, + pub timezone: String, + pub dispatch_mode: TriggerDispatchMode, + pub retry_max_attempts: u32, + pub retry_backoff: BackoffShape, + pub retry_base_ms: u32, + pub registered_by_principal: AdminUserId, +} + /// One match for the dispatcher's "which KV triggers fire on this /// event" lookup. Carries everything the dispatcher needs to construct /// the outbox row. @@ -206,6 +233,15 @@ pub trait TriggerRepo: Send + Sync { req: CreateDeadLetterTrigger, ) -> Result; + /// v1.1.4. `schedule` + `timezone` are validated before insert; an + /// invalid expression or unknown IANA name returns + /// `TriggerRepoError::Invalid`. + async fn create_cron_trigger( + &self, + app_id: AppId, + req: CreateCronTrigger, + ) -> Result; + async fn list_for_app(&self, app_id: AppId) -> Result, TriggerRepoError>; async fn get(&self, id: TriggerId) -> Result, TriggerRepoError>; @@ -453,6 +489,72 @@ impl TriggerRepo for PostgresTriggerRepo { }) } + async fn create_cron_trigger( + &self, + app_id: AppId, + req: CreateCronTrigger, + ) -> Result { + // Defense-in-depth validation (the admin endpoint validates too). + crate::cron_scheduler::validate_schedule(&req.schedule) + .map_err(|e| TriggerRepoError::Invalid(format!("invalid cron schedule: {e}")))?; + crate::cron_scheduler::validate_timezone(&req.timezone) + .map_err(|e| TriggerRepoError::Invalid(format!("invalid timezone: {e}")))?; + + let mut tx = self.pool.begin().await?; + let parent: TriggerRow = sqlx::query_as( + "INSERT INTO triggers ( \ + app_id, script_id, kind, enabled, dispatch_mode, \ + retry_max_attempts, retry_backoff, retry_base_ms, \ + registered_by_principal \ + ) VALUES ($1, $2, 'cron', TRUE, $3, $4, $5, $6, $7) \ + RETURNING id, app_id, script_id, kind, enabled, dispatch_mode, \ + retry_max_attempts, retry_backoff, retry_base_ms, \ + registered_by_principal, created_at, updated_at", + ) + .bind(app_id.into_inner()) + .bind(req.script_id.into_inner()) + .bind(req.dispatch_mode.as_str()) + .bind(i32::try_from(req.retry_max_attempts).unwrap_or(3)) + .bind(req.retry_backoff.as_str()) + .bind(i32::try_from(req.retry_base_ms).unwrap_or(1000)) + .bind(req.registered_by_principal.into_inner()) + .fetch_one(&mut *tx) + .await?; + + sqlx::query( + "INSERT INTO cron_trigger_details (trigger_id, schedule, timezone) \ + VALUES ($1, $2, $3)", + ) + .bind(parent.id) + .bind(&req.schedule) + .bind(&req.timezone) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(Trigger { + id: parent.id.into(), + app_id: parent.app_id.into(), + script_id: parent.script_id.into(), + kind: TriggerKind::Cron, + enabled: parent.enabled, + dispatch_mode: dispatch_from_str(&parent.dispatch_mode), + retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3), + retry_backoff: BackoffShape::from_wire(&parent.retry_backoff) + .unwrap_or(BackoffShape::Exponential), + retry_base_ms: u32::try_from(parent.retry_base_ms).unwrap_or(1000), + registered_by_principal: parent.registered_by_principal.into(), + created_at: parent.created_at, + updated_at: parent.updated_at, + details: TriggerDetails::Cron { + schedule: req.schedule, + timezone: req.timezone, + last_fired_at: None, + }, + }) + } + async fn list_for_app(&self, app_id: AppId) -> Result, TriggerRepoError> { let parents: Vec = sqlx::query_as( "SELECT id, app_id, script_id, kind, enabled, dispatch_mode, \ @@ -681,6 +783,20 @@ async fn hydrate_one(pool: &PgPool, parent: TriggerRow) -> Result { + let row: CronDetailRow = sqlx::query_as( + "SELECT schedule, timezone, last_fired_at \ + FROM cron_trigger_details WHERE trigger_id = $1", + ) + .bind(parent.id) + .fetch_one(pool) + .await?; + TriggerDetails::Cron { + schedule: row.schedule, + timezone: row.timezone, + last_fired_at: row.last_fired_at, + } + } }; Ok(Trigger { @@ -746,6 +862,13 @@ struct KvDetailRow { ops: Vec, } +#[derive(sqlx::FromRow)] +struct CronDetailRow { + schedule: String, + timezone: String, + last_fired_at: Option>, +} + #[derive(sqlx::FromRow)] #[allow(clippy::struct_field_names)] struct DlDetailRow { diff --git a/crates/manager-core/src/triggers_api.rs b/crates/manager-core/src/triggers_api.rs index 075bbcd..a3ff8d1 100644 --- a/crates/manager-core/src/triggers_api.rs +++ b/crates/manager-core/src/triggers_api.rs @@ -25,8 +25,8 @@ use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability}; use crate::repo::{ScriptRepository, ScriptRepositoryError}; use crate::trigger_config::{BackoffShape, TriggerConfig}; use crate::trigger_repo::{ - CreateDeadLetterTrigger, CreateDocsTrigger, CreateKvTrigger, Trigger, TriggerDispatchMode, - TriggerRepo, TriggerRepoError, + CreateCronTrigger, CreateDeadLetterTrigger, CreateDocsTrigger, CreateKvTrigger, Trigger, + TriggerDispatchMode, TriggerRepo, TriggerRepoError, }; #[derive(Clone)] @@ -53,6 +53,7 @@ pub fn triggers_router(state: TriggersState) -> Router { ) .route("/apps/{app_id}/triggers/kv", post(create_kv_trigger)) .route("/apps/{app_id}/triggers/docs", post(create_docs_trigger)) + .route("/apps/{app_id}/triggers/cron", post(create_cron_trigger)) .route( "/apps/{app_id}/triggers/dead_letter", post(create_dl_trigger), @@ -116,6 +117,28 @@ pub struct CreateDocsTriggerRequest { pub retry_base_ms: Option, } +/// v1.1.4 cron trigger. `schedule` is a 6-field cron expression (with +/// seconds); `timezone` is an IANA name (defaults to UTC if omitted). +#[derive(Debug, Deserialize)] +pub struct CreateCronTriggerRequest { + pub script_id: ScriptId, + pub schedule: String, + #[serde(default = "default_timezone")] + pub timezone: String, + #[serde(default = "default_dispatch")] + pub dispatch_mode: TriggerDispatchMode, + #[serde(default)] + pub retry_max_attempts: Option, + #[serde(default)] + pub retry_backoff: Option, + #[serde(default)] + pub retry_base_ms: Option, +} + +fn default_timezone() -> String { + "UTC".to_string() +} + #[derive(Debug, Deserialize)] pub struct CreateDeadLetterTriggerRequest { pub script_id: ScriptId, @@ -264,6 +287,47 @@ async fn create_docs_trigger( Ok((StatusCode::CREATED, Json(created))) } +async fn create_cron_trigger( + State(s): State, + Extension(principal): Extension, + Path(app_id): Path, + Json(input): Json, +) -> Result<(StatusCode, Json), TriggersApiError> { + ensure_app_exists(&*s.apps, app_id).await?; + require( + s.authz.as_ref(), + &principal, + Capability::AppManageTriggers(app_id), + ) + .await?; + + // Validate the schedule + timezone before touching the script repo + // so a bad expression fails fast with a clear 422. + crate::cron_scheduler::validate_schedule(&input.schedule) + .map_err(|e| TriggersApiError::Invalid(format!("invalid cron schedule: {e}")))?; + crate::cron_scheduler::validate_timezone(&input.timezone) + .map_err(|e| TriggersApiError::Invalid(format!("invalid timezone: {e}")))?; + + // v1.1.3 check: target script exists, lives in this app, is an + // endpoint (not a module). + validate_trigger_target(&*s.scripts, app_id, input.script_id).await?; + + let req = CreateCronTrigger { + script_id: input.script_id, + schedule: input.schedule, + timezone: input.timezone, + dispatch_mode: input.dispatch_mode, + retry_max_attempts: input + .retry_max_attempts + .unwrap_or(s.config.retry_max_attempts), + retry_backoff: input.retry_backoff.unwrap_or(s.config.retry_backoff), + retry_base_ms: input.retry_base_ms.unwrap_or(s.config.retry_base_ms), + registered_by_principal: principal.user_id, + }; + let created = s.triggers.create_cron_trigger(app_id, req).await?; + Ok((StatusCode::CREATED, Json(created))) +} + async fn create_dl_trigger( State(s): State, Extension(principal): Extension, @@ -420,8 +484,8 @@ mod tests { use super::*; use crate::app_repo::{AppLookup, AppRepository}; use crate::trigger_repo::{ - DeadLetterTriggerMatch, DocsTriggerMatch, KvTriggerMatch, Trigger, TriggerDetails, - TriggerRepo, TriggerRepoError, + CreateCronTrigger, DeadLetterTriggerMatch, DocsTriggerMatch, KvTriggerMatch, Trigger, + TriggerDetails, TriggerRepo, TriggerRepoError, }; use async_trait::async_trait; use chrono::Utc; @@ -523,6 +587,35 @@ mod tests { self.inner.lock().await.insert(id, trigger.clone()); Ok(trigger) } + async fn create_cron_trigger( + &self, + app_id: AppId, + req: CreateCronTrigger, + ) -> Result { + let now = Utc::now(); + let id = TriggerId::new(); + let trigger = Trigger { + id, + app_id, + script_id: req.script_id, + kind: crate::trigger_repo::TriggerKind::Cron, + enabled: true, + dispatch_mode: req.dispatch_mode, + retry_max_attempts: req.retry_max_attempts, + retry_backoff: req.retry_backoff, + retry_base_ms: req.retry_base_ms, + registered_by_principal: req.registered_by_principal, + created_at: now, + updated_at: now, + details: TriggerDetails::Cron { + schedule: req.schedule, + timezone: req.timezone, + last_fired_at: None, + }, + }; + self.inner.lock().await.insert(id, trigger.clone()); + Ok(trigger) + } async fn list_for_app(&self, app_id: AppId) -> Result, TriggerRepoError> { Ok(self .inner @@ -1281,6 +1374,169 @@ mod tests { ); } + // ---------------------------------------------------------------- + // v1.1.4: cron trigger create. + // ---------------------------------------------------------------- + + fn cron_req(script_id: ScriptId, schedule: &str, timezone: &str) -> CreateCronTriggerRequest { + CreateCronTriggerRequest { + script_id, + schedule: schedule.into(), + timezone: timezone.into(), + dispatch_mode: TriggerDispatchMode::Async, + retry_max_attempts: None, + retry_backoff: None, + retry_base_ms: None, + } + } + + #[tokio::test] + async fn cron_trigger_create_succeeds() { + let app_id = AppId::new(); + let script_id = ScriptId::new(); + let state = state_with_endpoint(Arc::new(AlwaysAllowAuthzRepo), app_id, script_id); + let (status, Json(trigger)) = create_cron_trigger( + State(state), + Extension(member_principal()), + Path(app_id), + Json(cron_req( + script_id, + "0 0 9 * * MON-FRI", + "America/Los_Angeles", + )), + ) + .await + .unwrap(); + assert_eq!(status, StatusCode::CREATED); + assert!(matches!( + trigger.kind, + crate::trigger_repo::TriggerKind::Cron + )); + match trigger.details { + TriggerDetails::Cron { + schedule, + timezone, + last_fired_at, + } => { + assert_eq!(schedule, "0 0 9 * * MON-FRI"); + assert_eq!(timezone, "America/Los_Angeles"); + assert!(last_fired_at.is_none()); + } + other => panic!("expected Cron details, got {other:?}"), + } + } + + #[tokio::test] + async fn cron_trigger_rejects_invalid_schedule() { + let app_id = AppId::new(); + let script_id = ScriptId::new(); + let state = state_with_endpoint(Arc::new(AlwaysAllowAuthzRepo), app_id, script_id); + let res = create_cron_trigger( + State(state), + Extension(member_principal()), + Path(app_id), + // 5-field expression — not the 6-field format we accept. + Json(cron_req(script_id, "* * * * *", "UTC")), + ) + .await; + let err = res.expect_err("invalid schedule should reject"); + let msg = match err { + TriggersApiError::Invalid(m) => m, + other => panic!("expected Invalid, got {other:?}"), + }; + assert!(msg.to_lowercase().contains("schedule"), "got {msg}"); + } + + #[tokio::test] + async fn cron_trigger_rejects_unknown_timezone() { + let app_id = AppId::new(); + let script_id = ScriptId::new(); + let state = state_with_endpoint(Arc::new(AlwaysAllowAuthzRepo), app_id, script_id); + let res = create_cron_trigger( + State(state), + Extension(member_principal()), + Path(app_id), + Json(cron_req(script_id, "0 * * * * *", "Mars/Phobos")), + ) + .await; + let err = res.expect_err("unknown timezone should reject"); + let msg = match err { + TriggersApiError::Invalid(m) => m, + other => panic!("expected Invalid, got {other:?}"), + }; + assert!(msg.to_lowercase().contains("timezone"), "got {msg}"); + } + + #[tokio::test] + async fn cron_trigger_rejects_module_target() { + let app_id = AppId::new(); + let script_id = ScriptId::new(); + let state = TriggersState { + triggers: Arc::new(InMemoryTriggerRepo::default()), + apps: InMemoryAppRepo::with(app_id), + authz: Arc::new(AlwaysAllowAuthzRepo), + scripts: InMemoryScriptRepo::with_module(app_id, script_id), + config: TriggerConfig::conservative(), + }; + let res = create_cron_trigger( + State(state), + Extension(member_principal()), + Path(app_id), + Json(cron_req(script_id, "0 * * * * *", "UTC")), + ) + .await; + let err = res.expect_err("module script should be rejected as cron target"); + let msg = match err { + TriggersApiError::Invalid(m) => m, + other => panic!("expected Invalid, got {other:?}"), + }; + assert!(msg.to_lowercase().contains("module"), "got {msg}"); + } + + #[tokio::test] + async fn cron_trigger_rejects_cross_app_script() { + // v1.1.3 isolation gap regression: app A cannot target app B's + // script via a cron trigger. + let app_a = AppId::new(); + let app_b = AppId::new(); + let script_id = ScriptId::new(); + let state = TriggersState { + triggers: Arc::new(InMemoryTriggerRepo::default()), + apps: InMemoryAppRepo::with(app_a), + authz: Arc::new(AlwaysAllowAuthzRepo), + scripts: InMemoryScriptRepo::with_endpoint(app_b, script_id), + config: TriggerConfig::conservative(), + }; + let res = create_cron_trigger( + State(state), + Extension(member_principal()), + Path(app_a), + Json(cron_req(script_id, "0 * * * * *", "UTC")), + ) + .await; + let err = res.expect_err("cross-app cron target should reject"); + let msg = match err { + TriggersApiError::Invalid(m) => m, + other => panic!("expected Invalid, got {other:?}"), + }; + assert!(msg.to_lowercase().contains("does not belong"), "got {msg}"); + } + + #[tokio::test] + async fn cron_trigger_member_without_role_is_forbidden() { + let app_id = AppId::new(); + let state = state_with(Arc::new(AlwaysDenyAuthzRepo), app_id); + let res = create_cron_trigger( + State(state), + Extension(member_principal()), + Path(app_id), + Json(cron_req(ScriptId::new(), "0 * * * * *", "UTC")), + ) + .await; + let err = res.expect_err("member without role should be forbidden"); + assert!(matches!(err, TriggersApiError::Forbidden)); + } + #[tokio::test] async fn kv_trigger_accepts_endpoint_target() { let app_id = AppId::new(); diff --git a/crates/manager-core/tests/expected_schema.txt b/crates/manager-core/tests/expected_schema.txt index ff7c50f..c444b4a 100644 --- a/crates/manager-core/tests/expected_schema.txt +++ b/crates/manager-core/tests/expected_schema.txt @@ -3,6 +3,16 @@ ## tables +table: abandoned_executions + id: uuid NOT NULL default=gen_random_uuid() + app_id: uuid NOT NULL + outbox_id: uuid NOT NULL + script_id: uuid NULL + inbox_id: uuid NOT NULL + status_code: integer NOT NULL + result_summary: text NULL + created_at: timestamp with time zone NOT NULL default=now() + table: admin_sessions token_hash: text NOT NULL user_id: uuid NOT NULL @@ -61,6 +71,48 @@ table: apps created_at: timestamp with time zone NOT NULL default=now() updated_at: timestamp with time zone NOT NULL default=now() +table: cron_trigger_details + trigger_id: uuid NOT NULL + schedule: text NOT NULL + timezone: text NOT NULL default='UTC'::text + last_fired_at: timestamp with time zone NULL + +table: dead_letter_trigger_details + trigger_id: uuid NOT NULL + source_filter: text NULL + trigger_id_filter: uuid NULL + script_id_filter: uuid NULL + +table: dead_letters + id: uuid NOT NULL default=gen_random_uuid() + app_id: uuid NOT NULL + original_event_id: uuid NOT NULL + source: text NOT NULL + op: text NOT NULL + trigger_id: uuid NULL + script_id: uuid NULL + payload: jsonb NOT NULL + attempt_count: integer NOT NULL + first_attempt_at: timestamp with time zone NOT NULL + last_attempt_at: timestamp with time zone NOT NULL + last_error: text NOT NULL + created_at: timestamp with time zone NOT NULL default=now() + resolved_at: timestamp with time zone NULL + resolution: text NULL + +table: docs + app_id: uuid NOT NULL + collection: text NOT NULL + id: uuid NOT NULL + data: jsonb NOT NULL + created_at: timestamp with time zone NOT NULL default=now() + updated_at: timestamp with time zone NOT NULL default=now() + +table: docs_trigger_details + trigger_id: uuid NOT NULL + collection_glob: text NOT NULL + ops: ARRAY NOT NULL + table: execution_logs id: uuid NOT NULL default=gen_random_uuid() script_id: uuid NOT NULL @@ -76,6 +128,36 @@ table: execution_logs created_at: timestamp with time zone NOT NULL default=now() app_id: uuid NOT NULL +table: kv_entries + app_id: uuid NOT NULL + collection: text NOT NULL + key: text NOT NULL + value: jsonb NOT NULL + created_at: timestamp with time zone NOT NULL default=now() + updated_at: timestamp with time zone NOT NULL default=now() + +table: kv_trigger_details + trigger_id: uuid NOT NULL + collection_glob: text NOT NULL + ops: ARRAY NOT NULL + +table: outbox + id: uuid NOT NULL default=gen_random_uuid() + app_id: uuid NOT NULL + source_kind: text NOT NULL + trigger_id: uuid NULL + script_id: uuid NULL + reply_to: uuid NULL + payload: jsonb NOT NULL + origin_principal: uuid NULL + trigger_depth: integer NOT NULL default=0 + root_execution_id: uuid NULL + attempt_count: integer NOT NULL default=0 + next_attempt_at: timestamp with time zone NOT NULL default=now() + claimed_at: timestamp with time zone NULL + claimed_by: text NULL + created_at: timestamp with time zone NOT NULL default=now() + table: routes id: uuid NOT NULL default=gen_random_uuid() script_id: uuid NOT NULL @@ -87,6 +169,13 @@ table: routes method: text NULL created_at: timestamp with time zone NOT NULL default=now() app_id: uuid NOT NULL + dispatch_mode: text NOT NULL default='sync'::text + +table: script_imports + app_id: uuid NOT NULL + importer_script_id: uuid NOT NULL + imported_script_id: uuid NOT NULL + created_at: timestamp with time zone NOT NULL default=now() table: scripts id: uuid NOT NULL default=gen_random_uuid() @@ -100,9 +189,28 @@ table: scripts updated_at: timestamp with time zone NOT NULL default=now() sandbox: jsonb NOT NULL default='{}'::jsonb app_id: uuid NOT NULL + kind: text NOT NULL default='endpoint'::text + +table: triggers + id: uuid NOT NULL default=gen_random_uuid() + app_id: uuid NOT NULL + script_id: uuid NOT NULL + kind: text NOT NULL + enabled: boolean NOT NULL default=true + dispatch_mode: text NOT NULL default='async'::text + retry_max_attempts: integer NOT NULL + retry_backoff: text NOT NULL + retry_base_ms: integer NOT NULL + registered_by_principal: uuid NOT NULL + created_at: timestamp with time zone NOT NULL default=now() + updated_at: timestamp with time zone NOT NULL default=now() ## indexes +indexes on abandoned_executions: + abandoned_executions_pkey: public.abandoned_executions USING btree (id) + idx_abandoned_executions_gc: public.abandoned_executions USING btree (created_at) + indexes on admin_sessions: admin_sessions_expiry_idx: public.admin_sessions USING btree (expires_at) admin_sessions_pkey: public.admin_sessions USING btree (token_hash) @@ -135,11 +243,43 @@ indexes on apps: apps_pkey: public.apps USING btree (id) apps_slug_key: public.apps USING btree (slug) +indexes on cron_trigger_details: + cron_trigger_details_pkey: public.cron_trigger_details USING btree (trigger_id) + idx_cron_triggers_due: public.cron_trigger_details USING btree (last_fired_at) + +indexes on dead_letter_trigger_details: + dead_letter_trigger_details_pkey: public.dead_letter_trigger_details USING btree (trigger_id) + +indexes on dead_letters: + dead_letters_pkey: public.dead_letters USING btree (id) + idx_dead_letters_app_unresolved: public.dead_letters USING btree (app_id) WHERE (resolved_at IS NULL) + idx_dead_letters_gc: public.dead_letters USING btree (created_at) + +indexes on docs: + docs_pkey: public.docs USING btree (app_id, collection, id) + idx_docs_app_collection: public.docs USING btree (app_id, collection) + idx_docs_data_gin: public.docs USING gin (data jsonb_path_ops) + +indexes on docs_trigger_details: + docs_trigger_details_pkey: public.docs_trigger_details USING btree (trigger_id) + indexes on execution_logs: execution_logs_app_id_created_at_idx: public.execution_logs USING btree (app_id, created_at DESC) execution_logs_pkey: public.execution_logs USING btree (id) execution_logs_script_id_created_at_idx: public.execution_logs USING btree (script_id, created_at DESC) +indexes on kv_entries: + idx_kv_entries_app_collection: public.kv_entries USING btree (app_id, collection) + kv_entries_pkey: public.kv_entries USING btree (app_id, collection, key) + +indexes on kv_trigger_details: + kv_trigger_details_pkey: public.kv_trigger_details USING btree (trigger_id) + +indexes on outbox: + idx_outbox_app: public.outbox USING btree (app_id) + idx_outbox_due: public.outbox USING btree (next_attempt_at) WHERE (claimed_at IS NULL) + outbox_pkey: public.outbox USING btree (id) + indexes on routes: routes_app_id_idx: public.routes USING btree (app_id) routes_lookup_idx: public.routes USING btree (host_kind, host) @@ -147,13 +287,27 @@ indexes on routes: routes_script_id_idx: public.routes USING btree (script_id) routes_unique_binding_idx: public.routes USING btree (app_id, host_kind, host, path_kind, path, COALESCE(method, ''::text)) +indexes on script_imports: + idx_script_imports_app: public.script_imports USING btree (app_id) + idx_script_imports_imported: public.script_imports USING btree (imported_script_id) + script_imports_pkey: public.script_imports USING btree (importer_script_id, imported_script_id) + indexes on scripts: + idx_scripts_app_kind: public.scripts USING btree (app_id, kind) scripts_app_id_idx: public.scripts USING btree (app_id) scripts_name_uidx: public.scripts USING btree (app_id, lower(name)) scripts_pkey: public.scripts USING btree (id) +indexes on triggers: + idx_triggers_app_kind_enabled: public.triggers USING btree (app_id, kind) WHERE (enabled = true) + triggers_pkey: public.triggers USING btree (id) + ## constraints +constraints on abandoned_executions: + [FOREIGN KEY] abandoned_executions_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE + [PRIMARY KEY] abandoned_executions_pkey: PRIMARY KEY (id) + constraints on admin_sessions: [FOREIGN KEY] admin_sessions_user_id_fkey: FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE CASCADE [PRIMARY KEY] admin_sessions_pkey: PRIMARY KEY (token_hash) @@ -189,25 +343,77 @@ constraints on apps: [PRIMARY KEY] apps_pkey: PRIMARY KEY (id) [UNIQUE] apps_slug_key: UNIQUE (slug) +constraints on cron_trigger_details: + [FOREIGN KEY] cron_trigger_details_trigger_id_fkey: FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE + [PRIMARY KEY] cron_trigger_details_pkey: PRIMARY KEY (trigger_id) + +constraints on dead_letter_trigger_details: + [FOREIGN KEY] dead_letter_trigger_details_trigger_id_fkey: FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE + [PRIMARY KEY] dead_letter_trigger_details_pkey: PRIMARY KEY (trigger_id) + +constraints on dead_letters: + [CHECK] dead_letters_resolution_check: CHECK ((resolution = ANY (ARRAY['replayed'::text, 'ignored'::text, 'handled_by_script'::text, 'handler_failed'::text]))) + [FOREIGN KEY] dead_letters_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE + [PRIMARY KEY] dead_letters_pkey: PRIMARY KEY (id) + +constraints on docs: + [FOREIGN KEY] docs_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE + [PRIMARY KEY] docs_pkey: PRIMARY KEY (app_id, collection, id) + +constraints on docs_trigger_details: + [FOREIGN KEY] docs_trigger_details_trigger_id_fkey: FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE + [PRIMARY KEY] docs_trigger_details_pkey: PRIMARY KEY (trigger_id) + constraints on execution_logs: [CHECK] execution_logs_status_check: CHECK ((status = ANY (ARRAY['success'::text, 'error'::text, 'timeout'::text, 'budget_exceeded'::text]))) [FOREIGN KEY] execution_logs_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE [FOREIGN KEY] execution_logs_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE [PRIMARY KEY] execution_logs_pkey: PRIMARY KEY (id) +constraints on kv_entries: + [FOREIGN KEY] kv_entries_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE + [PRIMARY KEY] kv_entries_pkey: PRIMARY KEY (app_id, collection, key) + +constraints on kv_trigger_details: + [FOREIGN KEY] kv_trigger_details_trigger_id_fkey: FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE + [PRIMARY KEY] kv_trigger_details_pkey: PRIMARY KEY (trigger_id) + +constraints on outbox: + [CHECK] outbox_source_kind_check: CHECK ((source_kind = ANY (ARRAY['http'::text, 'kv'::text, 'dead_letter'::text, 'docs'::text, 'cron'::text]))) + [FOREIGN KEY] outbox_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE + [PRIMARY KEY] outbox_pkey: PRIMARY KEY (id) + constraints on routes: + [CHECK] routes_dispatch_mode_check: CHECK ((dispatch_mode = ANY (ARRAY['sync'::text, 'async'::text]))) [CHECK] routes_host_kind_check: CHECK ((host_kind = ANY (ARRAY['any'::text, 'strict'::text, 'wildcard'::text]))) [CHECK] routes_path_kind_check: CHECK ((path_kind = ANY (ARRAY['exact'::text, 'prefix'::text, 'param'::text]))) [FOREIGN KEY] routes_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE [FOREIGN KEY] routes_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE [PRIMARY KEY] routes_pkey: PRIMARY KEY (id) +constraints on script_imports: + [FOREIGN KEY] script_imports_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE + [FOREIGN KEY] script_imports_imported_script_id_fkey: FOREIGN KEY (imported_script_id) REFERENCES scripts(id) ON DELETE CASCADE + [FOREIGN KEY] script_imports_importer_script_id_fkey: FOREIGN KEY (importer_script_id) REFERENCES scripts(id) ON DELETE CASCADE + [PRIMARY KEY] script_imports_pkey: PRIMARY KEY (importer_script_id, imported_script_id) + constraints on scripts: + [CHECK] scripts_kind_check: CHECK ((kind = ANY (ARRAY['endpoint'::text, 'module'::text]))) [CHECK] scripts_memory_limit_mb_check: CHECK (((memory_limit_mb > 0) AND (memory_limit_mb <= 2048))) + [CHECK] scripts_module_name_shape: CHECK (((kind <> 'module'::text) OR (name ~ '^[a-zA-Z_][a-zA-Z0-9_]{0,63}$'::text))) [CHECK] scripts_timeout_seconds_check: CHECK (((timeout_seconds > 0) AND (timeout_seconds <= 300))) [FOREIGN KEY] scripts_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE RESTRICT [PRIMARY KEY] scripts_pkey: PRIMARY KEY (id) +constraints on triggers: + [CHECK] triggers_dispatch_mode_check: CHECK ((dispatch_mode = ANY (ARRAY['sync'::text, 'async'::text]))) + [CHECK] triggers_kind_check: CHECK ((kind = ANY (ARRAY['kv'::text, 'dead_letter'::text, 'docs'::text, 'cron'::text]))) + [CHECK] triggers_retry_backoff_check: CHECK ((retry_backoff = ANY (ARRAY['exponential'::text, 'linear'::text, 'constant'::text]))) + [FOREIGN KEY] triggers_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE + [FOREIGN KEY] triggers_registered_by_principal_fkey: FOREIGN KEY (registered_by_principal) REFERENCES admin_users(id) ON DELETE CASCADE + [FOREIGN KEY] triggers_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE + [PRIMARY KEY] triggers_pkey: PRIMARY KEY (id) + ## applied migrations 0001: init 0002: sandbox @@ -215,3 +421,14 @@ constraints on scripts: 0004: admin auth 0005: apps 0006: users authz + 0007: kv + 0008: triggers + 0009: outbox + 0010: dead letters + 0011: abandoned executions + 0012: routes dispatch mode + 0013: docs + 0014: docs triggers + 0015: scripts kind + 0016: script imports + 0017: cron triggers diff --git a/crates/picloud/src/lib.rs b/crates/picloud/src/lib.rs index 72aa451..f60e4de 100644 --- a/crates/picloud/src/lib.rs +++ b/crates/picloud/src/lib.rs @@ -16,10 +16,10 @@ use picloud_manager_core::{ AdminPrincipalResolver, AdminSessionRepository, AdminState, AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState, AppDomainRepository, AppMembersRepository, AppMembersState, AppRepository, AppsState, AuthState, AuthzRepo, DeadLetterRepo, DeadLettersState, Dispatcher, - DocsServiceImpl, KvServiceImpl, OutboxEventEmitter, OutboxRepo, PostgresAbandonedRepo, - PostgresAdminSessionRepository, PostgresAdminUserRepository, PostgresApiKeyRepository, - PostgresAppDomainRepository, PostgresAppMembersRepository, PostgresAppRepository, - PostgresDeadLetterRepo, PostgresDeadLetterService, PostgresDocsRepo, + DocsServiceImpl, HttpConfig, HttpServiceImpl, KvServiceImpl, OutboxEventEmitter, OutboxRepo, + PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository, + PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository, + PostgresAppRepository, PostgresDeadLetterRepo, PostgresDeadLetterService, PostgresDocsRepo, PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresKvRepo, PostgresOutboxRepo, PostgresRouteRepository, PostgresScriptRepository, PostgresTriggerRepo, PrincipalResolver, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling, ScriptRepository, @@ -31,9 +31,9 @@ use picloud_orchestrator_core::{ LocalExecutorClient, }; use picloud_shared::{ - DeadLetterService, DocsService, ExecutionLogSink, InboxResolver, KvService, OutboxWriter, - ScriptValidator, ServiceEventEmitter, Services, API_VERSION, PRODUCT_VERSION, SDK_VERSION, - WIRE_VERSION, + DeadLetterService, DocsService, ExecutionLogSink, HttpService, InboxResolver, KvService, + OutboxWriter, ScriptValidator, ServiceEventEmitter, Services, API_VERSION, PRODUCT_VERSION, + SDK_VERSION, WIRE_VERSION, }; use sqlx::postgres::PgPoolOptions; use sqlx::PgPool; @@ -143,9 +143,21 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result { outbox_repo.clone(), authz.clone(), )); - let modules: Arc = - Arc::new(picloud_manager_core::PostgresModuleSource::new(pool)); - let services = Services::new(kv, docs, dl_service.clone(), events, modules); + let modules: Arc = Arc::new( + picloud_manager_core::PostgresModuleSource::new(pool.clone()), + ); + // v1.1.4 outbound HTTP. The reqwest client is built once here with + // the SSRF deny-list resolver. `PICLOUD_HTTP_ALLOW_PRIVATE=true` + // disables the deny-list entirely — dev/test only, so warn loudly. + let http_config = HttpConfig::from_env(); + if http_config.allow_private { + tracing::warn!( + "PICLOUD_HTTP_ALLOW_PRIVATE is set — the outbound-HTTP SSRF deny-list is DISABLED. \ + Scripts can reach loopback/private/link-local addresses. Do NOT use in production." + ); + } + let http: Arc = Arc::new(HttpServiceImpl::new(http_config, authz.clone())); + let services = Services::new(kv, docs, dl_service.clone(), events, modules, http); let engine = Arc::new(Engine::new(Limits::default(), services)); // Compile the routes table once at startup; admin writes refresh it. @@ -241,6 +253,10 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result { abandoned_repo.clone(), trigger_config.abandoned_retention_days, ); + // v1.1.4: cron scheduler. Polls cron_trigger_details on a tick and + // enqueues due triggers into the outbox; the dispatcher above + // delivers them like any other async trigger. + picloud_manager_core::spawn_cron_scheduler(pool, trigger_config.cron_tick_interval_ms); let triggers_state = TriggersState { triggers: trigger_repo, apps: apps_repo.clone(), diff --git a/crates/shared/src/http.rs b/crates/shared/src/http.rs new file mode 100644 index 0000000..6035c82 --- /dev/null +++ b/crates/shared/src/http.rs @@ -0,0 +1,137 @@ +//! `HttpService` — the v1.1.4 outbound-HTTP contract. +//! +//! Lives in `picloud-shared` (not `executor-core` or `manager-core`) +//! so the Rhai bridge and the manager-core reqwest-backed impl can both +//! depend on the same trait without dragging `executor-core` into +//! `manager-core`'s dep graph — mirrors [`crate::kv`]. +//! +//! Unlike KV/docs, `http::*` has no app-scoped data, so there is no +//! cross-app isolation boundary to enforce here. `cx.app_id` is still +//! forwarded for audit-log attribution and (future, v1.2) per-app rate +//! limits. The load-bearing security mechanism is the SSRF deny-list +//! applied to the *resolved IP* — that lives in the manager-core impl, +//! not in this contract. +//! +//! Body encoding + per-method dispatch happen in the Rhai bridge before +//! the request reaches this trait: the service receives an already- +//! encoded body plus a `content_type`, so the impl stays a thin +//! transport layer. + +use std::collections::BTreeMap; + +use async_trait::async_trait; +use thiserror::Error; + +use crate::SdkCallCx; + +/// A fully-resolved outbound request. The bridge builds this from the +/// script-facing `(url, body, opts)` arguments; the service backend +/// turns it into a real network call. +#[derive(Debug, Clone)] +pub struct HttpRequest { + /// Uppercased HTTP method (`GET`, `POST`, …). The escape-hatch + /// `http::request(method, …)` lets scripts pass arbitrary methods, + /// so the impl validates this rather than the bridge. + pub method: String, + pub url: String, + /// Caller-supplied headers, merged into the request. Header names + /// are case-insensitive on the wire; stored verbatim here. + pub headers: BTreeMap, + /// Already-encoded body. `None` means no body (GET/HEAD, or an + /// explicit `()` body). + pub body: Option>, + /// Content-Type the bridge chose for `body` (e.g. + /// `application/json`). Ignored when the caller set their own + /// `Content-Type` header. `None` when there is no body. + pub content_type: Option, + /// Total request budget in ms (already clamped to the 60s ceiling + /// by the bridge). + pub timeout_ms: u32, + pub follow_redirects: bool, + /// Max redirects to follow (already clamped to 10 by the bridge). + pub max_redirects: u32, + /// Script id for the default `User-Agent` and audit attribution. + /// `None` when unavailable (the bridge always sets it from + /// `cx`-adjacent context, but the field stays optional so the + /// trait isn't coupled to how the id is sourced). + pub script_id: Option, +} + +/// The response shape the bridge turns into a Rhai map. JSON parsing of +/// `body_raw` happens in the bridge (it needs the Rhai value types), so +/// the service returns only the raw string + lowercased headers. +#[derive(Debug, Clone)] +pub struct HttpResponse { + pub status: u16, + /// Header names lowercased (per the documented response shape). + pub headers: BTreeMap, + pub body_raw: String, +} + +/// Failure modes surfaced to the Rhai bridge. The bridge prefixes each +/// `Display` string with `"http: "`. **None of these may leak the +/// resolved IP** — the SSRF reason is a CIDR-category label only. +#[derive(Debug, Error)] +pub enum HttpError { + /// Caller principal lacked `AppHttpRequest`. Only raised when + /// `cx.principal.is_some()`; public-HTTP scripts skip the check. + #[error("forbidden")] + Forbidden, + + /// URL failed to parse, or carried no host. + #[error("invalid url: {0}")] + InvalidUrl(String), + + /// Scheme other than http/https (file, ftp, gopher, …). + #[error("scheme not allowed: {0}")] + BlockedScheme(String), + + /// Destination port is on the explicit block list (22, 25, 465, 587). + #[error("port not allowed: {0}")] + BlockedPort(u16), + + /// Resolved IP hit the SSRF deny-list. `reason` is a CIDR-category + /// label (e.g. "loopback", "private", "link-local") — never the IP. + #[error("blocked by SSRF policy: {0}")] + Ssrf(String), + + /// The request exceeded the wall-clock budget. + #[error("request timed out")] + Timeout, + + /// Request or response body exceeded the configured size cap. + /// `which` is `"request"` or `"response"`. + #[error("{0} body exceeds size limit")] + BodyTooLarge(&'static str), + + /// DNS / connect / TLS failure. The message is generic and MUST NOT + /// contain the resolved IP. + #[error("{0}")] + Network(String), + + /// Anything else the impl wants to surface (still safe to show a + /// script). + #[error("{0}")] + Backend(String), +} + +/// Stub used by the executor-core test harness so engine integration +/// tests (which don't make real network calls) can construct a +/// `Services` bundle. Every call errors so accidental use surfaces. +#[derive(Debug, Default, Clone, Copy)] +pub struct NoopHttpService; + +#[async_trait] +impl HttpService for NoopHttpService { + async fn request(&self, _cx: &SdkCallCx, _req: HttpRequest) -> Result { + Err(HttpError::Network("http is not wired in".into())) + } +} + +/// Outbound-HTTP contract. A single generic `request` method funnels +/// every verb (`get`/`post`/…/`request`); the bridge maps the +/// script-facing surface onto it. +#[async_trait] +pub trait HttpService: Send + Sync { + async fn request(&self, cx: &SdkCallCx, req: HttpRequest) -> Result; +} diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index 5c3f5c2..5e9bfbb 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -12,6 +12,7 @@ pub mod error; pub mod events; pub mod exec_summary; pub mod execution_log; +pub mod http; pub mod ids; pub mod inbox; pub mod kv; @@ -35,6 +36,7 @@ pub use error::Error; pub use events::{EmitError, NoopEventEmitter, ServiceEvent, ServiceEventEmitter}; pub use exec_summary::ExecResponseSummary; pub use execution_log::{ExecutionLog, ExecutionStatus}; +pub use http::{HttpError, HttpRequest, HttpResponse, HttpService, NoopHttpService}; pub use ids::{AdminUserId, ApiKeyId, AppId, ExecutionId, RequestId, ScriptId, TriggerId}; pub use inbox::{ InboxDeliveryOutcome, InboxFailureKind, InboxResolver, InboxResult, NoopInboxResolver, diff --git a/crates/shared/src/sdk_cx.rs b/crates/shared/src/sdk_cx.rs index 43e3d48..18d9005 100644 --- a/crates/shared/src/sdk_cx.rs +++ b/crates/shared/src/sdk_cx.rs @@ -12,7 +12,7 @@ //! the cx in is shared by both sides. Pure value type — no handles, no //! DB pool references, no allocations beyond what's in `Principal`. -use crate::{AppId, ExecutionId, Principal, RequestId, TriggerEvent}; +use crate::{AppId, ExecutionId, Principal, RequestId, ScriptId, TriggerEvent}; /// Per-invocation context for every stateful SDK service call. /// @@ -27,6 +27,11 @@ pub struct SdkCallCx { /// every `(app_id, …)` storage lookup the script makes. pub app_id: AppId, + /// The script being executed. Used for audit-log attribution and + /// the default outbound-HTTP `User-Agent` (`picloud/ + /// (script:)`). Added in v1.1.4 for the `http::*` SDK. + pub script_id: ScriptId, + /// Caller identity, when authenticated. `None` for unauthenticated /// data-plane HTTP requests (the common case for public endpoints); /// `Some` when the call came in via the dashboard, an API key, or a diff --git a/crates/shared/src/services.rs b/crates/shared/src/services.rs index 14de6fa..1ec4a93 100644 --- a/crates/shared/src/services.rs +++ b/crates/shared/src/services.rs @@ -20,8 +20,9 @@ use std::sync::Arc; use crate::{ - DeadLetterService, DocsService, KvService, ModuleSource, NoopDeadLetterService, - NoopDocsService, NoopEventEmitter, NoopKvService, NoopModuleSource, ServiceEventEmitter, + DeadLetterService, DocsService, HttpService, KvService, ModuleSource, NoopDeadLetterService, + NoopDocsService, NoopEventEmitter, NoopHttpService, NoopKvService, NoopModuleSource, + ServiceEventEmitter, }; /// SDK service bundle. See module docs for the lifecycle and the v1.1.x @@ -53,6 +54,12 @@ pub struct Services { /// `import`. Backed by Postgres in the picloud binary; in-memory /// fakes in resolver tests. pub modules: Arc, + + /// Outbound HTTP (v1.1.4). Scripts get `http::{get,post,…}`. + /// Backed by a reqwest client with the SSRF deny-list resolver in + /// the picloud binary; `NoopHttpService` in tests that don't make + /// network calls. + pub http: Arc, } impl Services { @@ -66,6 +73,7 @@ impl Services { dead_letters: Arc, events: Arc, modules: Arc, + http: Arc, ) -> Self { Self { kv, @@ -73,6 +81,7 @@ impl Services { dead_letters, events, modules, + http, } } @@ -89,6 +98,7 @@ impl Services { Arc::new(NoopDeadLetterService), Arc::new(NoopEventEmitter), Arc::new(NoopModuleSource), + Arc::new(NoopHttpService), ) } } diff --git a/crates/shared/src/trigger_event.rs b/crates/shared/src/trigger_event.rs index 3095c36..69a679f 100644 --- a/crates/shared/src/trigger_event.rs +++ b/crates/shared/src/trigger_event.rs @@ -111,6 +111,18 @@ pub enum TriggerEvent { prev_data: Option, }, + /// A cron schedule fired this handler. v1.1.4. Carries the + /// schedule + timezone the trigger was configured with, the + /// canonical cron moment (`scheduled_at`, the instant the + /// expression *meant*), and when the scheduler actually enqueued + /// the fire (`fired_at`). Surfaced to scripts as `ctx.event.cron`. + Cron { + schedule: String, + timezone: String, + scheduled_at: DateTime, + fired_at: DateTime, + }, + /// A dead-letter row fired this handler. The original event is /// nested verbatim plus the dead-letter metadata the design notes /// §4 require. @@ -135,6 +147,7 @@ impl TriggerEvent { match self { Self::Kv { .. } => "kv", Self::Docs { .. } => "docs", + Self::Cron { .. } => "cron", Self::DeadLetter { .. } => "dead_letter", } } diff --git a/crates/shared/src/version.rs b/crates/shared/src/version.rs index 295eb98..42c82d7 100644 --- a/crates/shared/src/version.rs +++ b/crates/shared/src/version.rs @@ -33,7 +33,13 @@ pub const PRODUCT_VERSION: &str = env!("CARGO_PKG_VERSION"); /// app. Cross-app imports are unreachable (the `name` argument carries /// no `app_id`). Modules expose `fn`/`const` declarations only; /// top-level statements are rejected at create-time. -pub const SDK_VERSION: &str = "1.4"; +/// +/// 1.5 additions (v1.1.4): `http::{get,post,put,patch,delete,head, +/// post_form,request}` for outbound HTTP from scripts (guarded by an +/// SSRF deny-list on the resolved IP); `ctx.event.cron` for cron-trigger +/// handlers (carries `schedule`, `timezone`, `scheduled_at`, `fired_at`). +/// The `Services` bundle gains `http: Arc`. +pub const SDK_VERSION: &str = "1.5"; /// HTTP API major version. Appears in URL paths as `/api/v{N}/...`. /// Bump (new integer + new URL prefix) when the request/response diff --git a/dashboard/package.json b/dashboard/package.json index c9e2142..85bb210 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -1,6 +1,6 @@ { "name": "picloud-dashboard", - "version": "0.9.0", + "version": "0.10.0", "private": true, "type": "module", "scripts": { diff --git a/dashboard/src/lib/api.ts b/dashboard/src/lib/api.ts index 62bf0f1..a58cd20 100644 --- a/dashboard/src/lib/api.ts +++ b/dashboard/src/lib/api.ts @@ -211,6 +211,42 @@ export interface DeadLetterRow { resolution: 'replayed' | 'ignored' | 'handled_by_script' | 'handler_failed' | null; } +export type TriggerKind = 'kv' | 'docs' | 'dead_letter' | 'cron'; +export type TriggerDispatchMode = 'sync' | 'async'; + +/// Per-kind detail, tagged by `kind` to match the Rust serde shape. +export type TriggerDetails = + | { kind: 'kv'; collection_glob: string; ops: string[] } + | { kind: 'docs'; collection_glob: string; ops: string[] } + | { kind: 'dead_letter'; source_filter?: string; trigger_id_filter?: string; script_id_filter?: string } + | { kind: 'cron'; schedule: string; timezone: string; last_fired_at?: string | null }; + +export interface Trigger { + id: string; + app_id: string; + script_id: string; + kind: TriggerKind; + enabled: boolean; + dispatch_mode: TriggerDispatchMode; + retry_max_attempts: number; + retry_backoff: 'exponential' | 'linear' | 'constant'; + retry_base_ms: number; + registered_by_principal: string; + created_at: string; + updated_at: string; + details: TriggerDetails; +} + +export interface CreateCronTriggerInput { + script_id: string; + schedule: string; + timezone: string; + dispatch_mode?: TriggerDispatchMode; + retry_max_attempts?: number; + retry_backoff?: 'exponential' | 'linear' | 'constant'; + retry_base_ms?: number; +} + export interface ExecutionResult { status: number; headers: Record; @@ -572,6 +608,23 @@ export const api = { ) }, + triggers: { + list: (idOrSlug: string) => + adminRequest<{ triggers: Trigger[] }>( + `/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers` + ), + createCron: (idOrSlug: string, input: CreateCronTriggerInput) => + adminRequest( + `/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers/cron`, + { method: 'POST', body: JSON.stringify(input) } + ), + remove: (idOrSlug: string, triggerId: string) => + adminRequest( + `/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers/${triggerId}`, + { method: 'DELETE' } + ) + }, + execute: async ( id: string, body: unknown, diff --git a/dashboard/src/routes/apps/[slug]/+page.svelte b/dashboard/src/routes/apps/[slug]/+page.svelte index 34e4a73..2e34ead 100644 --- a/dashboard/src/routes/apps/[slug]/+page.svelte +++ b/dashboard/src/routes/apps/[slug]/+page.svelte @@ -10,7 +10,8 @@ type AppDomain, type AppMemberDto, type AppRole, - type Script + type Script, + type Trigger } from '$lib/api'; import CodeEditor from '$lib/CodeEditor.svelte'; import ConfirmModal from '$lib/ConfirmModal.svelte'; @@ -24,7 +25,26 @@ const SAMPLE_SOURCE = '#{\n statusCode: 200,\n body: #{ ok: true, echo: ctx.request.body }\n}'; - type Tab = 'scripts' | 'domains' | 'members' | 'settings'; + type Tab = 'scripts' | 'domains' | 'members' | 'settings' | 'triggers'; + + // Common IANA timezones offered in the cron form dropdown. Not + // exhaustive — the backend validates any IANA name via chrono-tz. + const COMMON_TIMEZONES = [ + 'UTC', + 'America/Los_Angeles', + 'America/Denver', + 'America/Chicago', + 'America/New_York', + 'America/Sao_Paulo', + 'Europe/London', + 'Europe/Berlin', + 'Europe/Paris', + 'Europe/Moscow', + 'Asia/Kolkata', + 'Asia/Shanghai', + 'Asia/Tokyo', + 'Australia/Sydney' + ]; let slug = $derived(page.params.slug ?? ''); let app = $state(null); @@ -91,6 +111,63 @@ let removingDomain = $state(false); let removeDomainError = $state(null); + // Triggers tab (v1.1.4 — cron triggers). Admin-gated, like Members. + let triggers = $state([]); + let createCronScriptId = $state(''); + let createCronSchedule = $state('0 0 9 * * MON-FRI'); + let createCronTimezone = $state('UTC'); + let creatingCron = $state(false); + let createCronError = $state(null); + let triggerToRemove = $state(null); + let removingTrigger = $state(false); + // Endpoint scripts only — modules can't be trigger targets. + const endpointScripts = $derived(scripts.filter((s) => s.kind === 'endpoint')); + + async function loadTriggers(idOrSlug: string) { + try { + const r = await api.triggers.list(idOrSlug); + triggers = r.triggers; + } catch { + triggers = []; + } + } + + async function submitCreateCron(e: SubmitEvent) { + e.preventDefault(); + if (!app) return; + creatingCron = true; + createCronError = null; + try { + await api.triggers.createCron(app.id, { + script_id: createCronScriptId, + schedule: createCronSchedule.trim(), + timezone: createCronTimezone + }); + createCronScriptId = ''; + await loadTriggers(app.id); + } catch (err) { + createCronError = + err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err); + } finally { + creatingCron = false; + } + } + + async function confirmRemoveTrigger() { + if (!app || !triggerToRemove) return; + removingTrigger = true; + try { + await api.triggers.remove(app.id, triggerToRemove.id); + triggerToRemove = null; + await loadTriggers(app.id); + } catch (err) { + createCronError = + err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err); + } finally { + removingTrigger = false; + } + } + // Members tab let eligibleUsers = $state([]); let eligibleLoadError = $state(null); @@ -131,7 +208,7 @@ loadDeadLetterCount(app.id) ]; if (canAdmin) { - loaders.push(loadMembers(app.id), loadEligibleUsers()); + loaders.push(loadMembers(app.id), loadEligibleUsers(), loadTriggers(app.id)); } await Promise.all(loaders); } catch (e) { @@ -398,7 +475,10 @@ // backend still 403s the underlying calls, but no point showing an // empty tab. $effect(() => { - if (!canAdmin && (activeTab === 'settings' || activeTab === 'members')) { + if ( + !canAdmin && + (activeTab === 'settings' || activeTab === 'members' || activeTab === 'triggers') + ) { activeTab = 'scripts'; } }); @@ -440,6 +520,11 @@ class:active={activeTab === 'members'} onclick={() => (activeTab = 'members')}>Members ({members.length}) + + + + + {#if triggers.length === 0} +

No triggers in this app yet.

+ {:else} +
    + {#each triggers as t (t.id)} +
  • +
    + {t.kind} + {#if t.details.kind === 'cron'} + {t.details.schedule} + — {t.details.timezone} + + last fired: {t.details.last_fired_at ?? 'never'} + + {:else if t.details.kind === 'kv' || t.details.kind === 'docs'} + {t.details.collection_glob} + — {t.details.ops.join(', ') || 'any op'} + {/if} + → {t.script_id} +
    + +
  • + {/each} +
+ {/if} + {:else if activeTab === 'settings' && canAdmin}

Settings

@@ -855,6 +1025,23 @@ {/if} {/if} + + {#if triggerToRemove} + (triggerToRemove = null)} + > +

+ This {triggerToRemove.kind} trigger will stop firing. The target + script is not affected. +

+
+ {/if} {/if}