feat(v1.1.4): outbound HTTP SDK + cron triggers

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) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-03 20:23:18 +02:00
parent 6f17259e06
commit 10b5f655d5
39 changed files with 3828 additions and 53 deletions

137
crates/shared/src/http.rs Normal file
View File

@@ -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<String, String>,
/// Already-encoded body. `None` means no body (GET/HEAD, or an
/// explicit `()` body).
pub body: Option<Vec<u8>>,
/// 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<String>,
/// 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<String>,
}
/// 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<String, String>,
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<HttpResponse, HttpError> {
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<HttpResponse, HttpError>;
}

View File

@@ -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,

View File

@@ -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/<v>
/// (script:<id>)`). 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

View File

@@ -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<dyn ModuleSource>,
/// 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<dyn HttpService>,
}
impl Services {
@@ -66,6 +73,7 @@ impl Services {
dead_letters: Arc<dyn DeadLetterService>,
events: Arc<dyn ServiceEventEmitter>,
modules: Arc<dyn ModuleSource>,
http: Arc<dyn HttpService>,
) -> 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),
)
}
}

View File

@@ -111,6 +111,18 @@ pub enum TriggerEvent {
prev_data: Option<serde_json::Value>,
},
/// 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<Utc>,
fired_at: DateTime<Utc>,
},
/// 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",
}
}

View File

@@ -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<dyn HttpService>`.
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