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>
320 lines
9.9 KiB
Rust
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()
|
|
}
|
|
}
|