Files
PiCloud/crates/manager-core/src/dead_letters_api.rs
MechaCat02 10b5f655d5 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>
2026-06-03 20:23:18 +02:00

320 lines
9.9 KiB
Rust

//! `/api/v1/admin/apps/{id}/dead_letters/*` — dashboard surface for
//! the no-default-handler model (design notes §4).
//!
//! Endpoints:
//! - `GET /apps/{id}/dead_letters?unresolved=true` — list view
//! - `GET /apps/{id}/dead_letters/count` — badge count
//! - `GET /apps/{id}/dead_letters/{dl_id}` — row detail
//! - `POST /apps/{id}/dead_letters/{dl_id}/replay` — re-enqueue
//! - `POST /apps/{id}/dead_letters/{dl_id}/resolve` — mark resolved
//!
//! All gated on `Capability::AppDeadLetterManage(app_id)`.
use std::sync::Arc;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response};
use axum::routing::{get, post};
use axum::{Extension, Router};
use picloud_shared::{AppId, DeadLetterId, DeadLetterService, Principal, SdkCallCx};
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::app_repo::AppRepository;
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
use crate::dead_letter_repo::{DeadLetterRepo, DeadLetterRepoError, DeadLetterRow};
#[derive(Clone)]
pub struct DeadLettersState {
pub repo: Arc<dyn DeadLetterRepo>,
pub service: Arc<dyn DeadLetterService>,
pub apps: Arc<dyn AppRepository>,
pub authz: Arc<dyn AuthzRepo>,
}
pub fn dead_letters_router(state: DeadLettersState) -> Router {
Router::new()
.route("/apps/{app_id}/dead_letters", get(list))
.route("/apps/{app_id}/dead_letters/count", get(count))
.route("/apps/{app_id}/dead_letters/{dl_id}", get(detail))
.route("/apps/{app_id}/dead_letters/{dl_id}/replay", post(replay))
.route("/apps/{app_id}/dead_letters/{dl_id}/resolve", post(resolve))
.with_state(state)
}
#[derive(Debug, Deserialize)]
pub struct ListQuery {
#[serde(default)]
pub unresolved: bool,
#[serde(default = "default_limit")]
pub limit: i64,
#[serde(default)]
pub offset: i64,
}
const fn default_limit() -> i64 {
50
}
#[derive(Debug, Serialize)]
pub struct ListResponse {
pub dead_letters: Vec<DeadLetterDto>,
}
#[derive(Debug, Serialize)]
pub struct CountResponse {
pub unresolved: i64,
}
#[derive(Debug, Deserialize)]
pub struct ResolveBody {
pub reason: String,
}
#[derive(Debug, Serialize)]
pub struct DeadLetterDto {
pub id: DeadLetterId,
pub app_id: AppId,
pub source: String,
pub op: String,
pub trigger_id: Option<picloud_shared::TriggerId>,
pub script_id: Option<picloud_shared::ScriptId>,
pub payload: serde_json::Value,
pub attempt_count: u32,
pub first_attempt_at: chrono::DateTime<chrono::Utc>,
pub last_attempt_at: chrono::DateTime<chrono::Utc>,
pub last_error: String,
pub created_at: chrono::DateTime<chrono::Utc>,
pub resolved_at: Option<chrono::DateTime<chrono::Utc>>,
pub resolution: Option<String>,
}
impl From<DeadLetterRow> for DeadLetterDto {
fn from(r: DeadLetterRow) -> Self {
Self {
id: r.id,
app_id: r.app_id,
source: r.source,
op: r.op,
trigger_id: r.trigger_id,
script_id: r.script_id,
payload: r.payload,
attempt_count: r.attempt_count,
first_attempt_at: r.first_attempt_at,
last_attempt_at: r.last_attempt_at,
last_error: r.last_error,
created_at: r.created_at,
resolved_at: r.resolved_at,
resolution: r.resolution,
}
}
}
async fn list(
State(s): State<DeadLettersState>,
Extension(principal): Extension<Principal>,
Path(app_id): Path<AppId>,
Query(q): Query<ListQuery>,
) -> Result<Json<ListResponse>, DeadLettersApiError> {
ensure_app(&*s.apps, app_id).await?;
require(
s.authz.as_ref(),
&principal,
Capability::AppDeadLetterManage(app_id),
)
.await?;
let rows = s
.repo
.list_for_app(app_id, q.unresolved, q.limit.clamp(1, 200), q.offset.max(0))
.await?;
Ok(Json(ListResponse {
dead_letters: rows.into_iter().map(Into::into).collect(),
}))
}
async fn count(
State(s): State<DeadLettersState>,
Extension(principal): Extension<Principal>,
Path(app_id): Path<AppId>,
) -> Result<Json<CountResponse>, DeadLettersApiError> {
ensure_app(&*s.apps, app_id).await?;
require(
s.authz.as_ref(),
&principal,
Capability::AppDeadLetterManage(app_id),
)
.await?;
let n = s.repo.unresolved_count(app_id).await?;
Ok(Json(CountResponse { unresolved: n }))
}
async fn detail(
State(s): State<DeadLettersState>,
Extension(principal): Extension<Principal>,
Path((app_id, dl_id)): Path<(AppId, DeadLetterId)>,
) -> Result<Json<DeadLetterDto>, DeadLettersApiError> {
ensure_app(&*s.apps, app_id).await?;
require(
s.authz.as_ref(),
&principal,
Capability::AppDeadLetterManage(app_id),
)
.await?;
let row = s
.repo
.get(dl_id)
.await?
.ok_or(DeadLettersApiError::NotFound(dl_id))?;
if row.app_id != app_id {
return Err(DeadLettersApiError::NotFound(dl_id));
}
Ok(Json(row.into()))
}
async fn replay(
State(s): State<DeadLettersState>,
Extension(principal): Extension<Principal>,
Path((app_id, dl_id)): Path<(AppId, DeadLetterId)>,
) -> Result<StatusCode, DeadLettersApiError> {
ensure_app(&*s.apps, app_id).await?;
// Authz handled inside the service via SdkCallCx.
let cx = admin_cx(app_id, &principal);
s.service
.replay(&cx, dl_id)
.await
.map_err(map_service_err)?;
Ok(StatusCode::NO_CONTENT)
}
async fn resolve(
State(s): State<DeadLettersState>,
Extension(principal): Extension<Principal>,
Path((app_id, dl_id)): Path<(AppId, DeadLetterId)>,
Json(body): Json<ResolveBody>,
) -> Result<StatusCode, DeadLettersApiError> {
ensure_app(&*s.apps, app_id).await?;
let cx = admin_cx(app_id, &principal);
s.service
.resolve(&cx, dl_id, &body.reason)
.await
.map_err(map_service_err)?;
Ok(StatusCode::NO_CONTENT)
}
/// Synthesize an `SdkCallCx` for the admin path. The service layer
/// reads `cx.app_id` + `cx.principal` and ignores the trigger /
/// execution fields, so the per-call ids are arbitrary.
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(),
trigger_depth: 0,
root_execution_id: picloud_shared::ExecutionId::new(),
is_dead_letter_handler: false,
event: None,
}
}
async fn ensure_app(apps: &dyn AppRepository, app_id: AppId) -> Result<(), DeadLettersApiError> {
apps.get_by_id(app_id)
.await
.map_err(|e| DeadLettersApiError::Backend(e.to_string()))?
.ok_or_else(|| DeadLettersApiError::AppNotFound(app_id.to_string()))?;
Ok(())
}
fn map_service_err(e: picloud_shared::DeadLetterError) -> DeadLettersApiError {
match e {
picloud_shared::DeadLetterError::NotFound => {
DeadLettersApiError::NotFound(DeadLetterId::new())
}
picloud_shared::DeadLetterError::Forbidden => DeadLettersApiError::Forbidden,
picloud_shared::DeadLetterError::InvalidResolution(s) => {
DeadLettersApiError::Invalid(format!("invalid resolution: {s}"))
}
picloud_shared::DeadLetterError::Backend(s) => DeadLettersApiError::Backend(s),
}
}
#[derive(Debug, thiserror::Error)]
pub enum DeadLettersApiError {
#[error("app not found: {0}")]
AppNotFound(String),
#[error("dead-letter not found: {0}")]
NotFound(DeadLetterId),
#[error("invalid: {0}")]
Invalid(String),
#[error("forbidden")]
Forbidden,
#[error("authorization repo error: {0}")]
AuthzRepo(String),
#[error("dead-letter backend: {0}")]
Backend(String),
}
impl From<AuthzDenied> for DeadLettersApiError {
fn from(d: AuthzDenied) -> Self {
match d {
AuthzDenied::Denied => Self::Forbidden,
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
}
}
}
impl From<AuthzError> for DeadLettersApiError {
fn from(e: AuthzError) -> Self {
Self::AuthzRepo(e.to_string())
}
}
impl From<DeadLetterRepoError> for DeadLettersApiError {
fn from(e: DeadLetterRepoError) -> Self {
match e {
DeadLetterRepoError::NotFound(id) => Self::NotFound(id),
DeadLetterRepoError::InvalidResolution(s) => Self::Invalid(s),
DeadLetterRepoError::Db(e) => Self::Backend(e.to_string()),
}
}
}
impl IntoResponse for DeadLettersApiError {
fn into_response(self) -> Response {
let (status, body) = match &self {
Self::AppNotFound(_) | Self::NotFound(_) => {
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
}
Self::Invalid(_) => (
StatusCode::UNPROCESSABLE_ENTITY,
json!({ "error": self.to_string() }),
),
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
Self::AuthzRepo(e) => {
tracing::error!(error = %e, "dead_letters authz repo error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
Self::Backend(e) => {
tracing::error!(error = %e, "dead_letters api backend error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
};
(status, Json(body)).into_response()
}
}