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

View File

@@ -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<ScriptId>,
},
/// 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<DateTime<Utc>>,
},
}
/// 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<Trigger, TriggerRepoError>;
/// 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<Trigger, TriggerRepoError>;
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Trigger>, TriggerRepoError>;
async fn get(&self, id: TriggerId) -> Result<Option<Trigger>, TriggerRepoError>;
@@ -453,6 +489,72 @@ impl TriggerRepo for PostgresTriggerRepo {
})
}
async fn create_cron_trigger(
&self,
app_id: AppId,
req: CreateCronTrigger,
) -> Result<Trigger, TriggerRepoError> {
// 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<Vec<Trigger>, TriggerRepoError> {
let parents: Vec<TriggerRow> = 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<Trigger, Trigg
script_id_filter: row.script_id_filter.map(Into::into),
}
}
TriggerKind::Cron => {
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<String>,
}
#[derive(sqlx::FromRow)]
struct CronDetailRow {
schedule: String,
timezone: String,
last_fired_at: Option<DateTime<Utc>>,
}
#[derive(sqlx::FromRow)]
#[allow(clippy::struct_field_names)]
struct DlDetailRow {