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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user