Phase 4-lite C2. Lets a group own scripts (templates inherited by descendant
apps), with the create/list/manage surface — but not yet the inherited
resolution (binding + runtime), which is C3.
Capabilities: add `GroupScriptsRead` (viewer+ on the group → script:read) and
`GroupScriptsWrite` (editor+ → script:write), mirroring the group-vars tier and
resolved through the same group-ancestor walk.
API:
* New `group_scripts_api`: `POST/GET /groups/{id}/scripts` — create a
group-owned endpoint script and list a group's own (non-inherited) rows.
Phase 4-lite is endpoint-only and self-contained: `kind=module` and any
`import` are rejected (group modules + the lexical resolver are Phase 4b).
Owner resolved first (slug-or-uuid); capability bound to the resolved id.
* The by-id `/scripts/{id}` get/update/delete/logs handlers are now
owner-polymorphic via a `script_cap` helper: app-owned scripts gate on
`App*` exactly as before; group-owned on `GroupScripts*`. This is what
makes `deploy --group` idempotent (update reuses the by-id PUT) and lets a
group script be deleted by id. (C1 had these fail closed for groups.)
Repo: `list_for_group(group_id)` (the group's own rows).
CLI: `pic scripts ls --group <g>`, `pic scripts deploy --group <g>` (create or
update by name; `--app`/`--group` are mutually exclusive, exactly one
required). `pic scripts delete <id>` already works for group scripts via the
owner-polymorphic by-id route.
Live-validated against the dev DB: create → update (v2 via the by-id PUT) →
list → delete, plus module rejection and the polymorphic row shape
(`app_id NULL`, `group_id` set). Group scripts still can't be routed/triggered
or invoked — that lands in C3.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
543 lines
19 KiB
Rust
543 lines
19 KiB
Rust
//! Control-plane HTTP surface. Mounted by the `picloud` all-in-one
|
|
//! binary under `/api/admin` and by the future split `picloud-manager`
|
|
//! binary at its own root.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use axum::{
|
|
extract::{Path, Query, State},
|
|
http::StatusCode,
|
|
response::{IntoResponse, Response},
|
|
routing::get,
|
|
Extension, Json, Router,
|
|
};
|
|
use picloud_shared::{
|
|
AppId, ExecutionLog, ExecutionSource, GroupId, InstanceRole, Principal, Script, ScriptId,
|
|
ScriptKind, ScriptOwner, ScriptSandbox, ScriptValidator, ValidatedScript, ValidationError,
|
|
};
|
|
use serde::Deserialize;
|
|
|
|
use crate::app_repo::AppRepository;
|
|
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
|
|
use crate::repo::{
|
|
ExecutionLogRepository, NewScript, ScriptPatch, ScriptRepository, ScriptRepositoryError,
|
|
};
|
|
use crate::sandbox::{CeilingError, SandboxCeiling};
|
|
|
|
/// State shared by control-plane handlers. Separates concerns so the
|
|
/// manager can validate at upload time without depending on the
|
|
/// concrete executor-core types.
|
|
pub struct AdminState<R, L> {
|
|
pub repo: Arc<R>,
|
|
pub logs: Arc<L>,
|
|
/// App lookups: validates `app_id` on create, resolves `?app=<slug>`
|
|
/// filter on list. Trait-object so apps_repo can stay separate.
|
|
pub apps: Arc<dyn AppRepository>,
|
|
/// Phase 3.5 capability checks — every script handler resolves
|
|
/// `AppRead/Write/LogRead(script.app_id)` against this repo after
|
|
/// loading the resource.
|
|
pub authz: Arc<dyn AuthzRepo>,
|
|
pub validator: Arc<dyn ScriptValidator>,
|
|
pub sandbox_ceiling: SandboxCeiling,
|
|
}
|
|
|
|
impl<R, L> Clone for AdminState<R, L> {
|
|
fn clone(&self) -> Self {
|
|
Self {
|
|
repo: self.repo.clone(),
|
|
logs: self.logs.clone(),
|
|
apps: self.apps.clone(),
|
|
authz: self.authz.clone(),
|
|
validator: self.validator.clone(),
|
|
sandbox_ceiling: self.sandbox_ceiling,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Build the admin router. The caller (binary) chooses where to mount
|
|
/// it (typically `Router::new().nest("/api/admin", admin_router(state))`).
|
|
pub fn admin_router<R, L>(state: AdminState<R, L>) -> Router
|
|
where
|
|
R: ScriptRepository + 'static,
|
|
L: ExecutionLogRepository + 'static,
|
|
{
|
|
Router::new()
|
|
.route(
|
|
"/scripts",
|
|
get(list_scripts::<R, L>).post(create_script::<R, L>),
|
|
)
|
|
.route(
|
|
"/scripts/{id}",
|
|
get(get_script::<R, L>)
|
|
.put(update_script::<R, L>)
|
|
.delete(delete_script::<R, L>),
|
|
)
|
|
.route("/scripts/{id}/logs", get(list_logs::<R, L>))
|
|
.with_state(state)
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// DTOs
|
|
// ----------------------------------------------------------------------------
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct CreateScriptRequest {
|
|
/// Owning app. Required since Phase 3b — scripts cannot exist
|
|
/// outside an app. Use `/api/v1/admin/apps` to list known ids.
|
|
pub app_id: AppId,
|
|
pub name: String,
|
|
pub description: Option<String>,
|
|
pub source: String,
|
|
/// v1.1.3: `endpoint` (default — handles HTTP routes / trigger
|
|
/// targets) or `module` (library of fn/const imported by other
|
|
/// scripts). Modules reject route binding and trigger creation.
|
|
#[serde(default)]
|
|
pub kind: ScriptKind,
|
|
pub timeout_seconds: Option<i32>,
|
|
pub memory_limit_mb: Option<i32>,
|
|
/// Sandbox overrides; absent or empty `{}` means "use platform
|
|
/// defaults". Each non-null field is checked against the admin
|
|
/// ceiling at write time.
|
|
#[serde(default)]
|
|
pub sandbox: ScriptSandbox,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct ListScriptsQuery {
|
|
/// Optional filter: list scripts belonging to a single app, by id
|
|
/// or slug. Absent = all scripts across all apps (admin-global view).
|
|
#[serde(default)]
|
|
pub app: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct UpdateScriptRequest {
|
|
pub name: Option<String>,
|
|
// Double Option lets clients explicitly clear the description by
|
|
// sending `"description": null`; an absent field leaves it alone.
|
|
#[serde(default, deserialize_with = "deserialize_optional_optional")]
|
|
#[allow(clippy::option_option)]
|
|
pub description: Option<Option<String>>,
|
|
pub source: Option<String>,
|
|
pub timeout_seconds: Option<i32>,
|
|
pub memory_limit_mb: Option<i32>,
|
|
/// `Some(sandbox)` replaces the stored overrides wholesale (use
|
|
/// `Some(ScriptSandbox::empty())` to clear them). Absent leaves
|
|
/// the stored value unchanged.
|
|
pub sandbox: Option<ScriptSandbox>,
|
|
/// v1.1.3: `Some(kind)` changes the script's role. Transitions to
|
|
/// `Module` are rejected if any routes or triggers still reference
|
|
/// the script. `module → endpoint` is always allowed.
|
|
pub kind: Option<ScriptKind>,
|
|
}
|
|
|
|
#[allow(clippy::option_option)]
|
|
fn deserialize_optional_optional<'de, D>(d: D) -> Result<Option<Option<String>>, D::Error>
|
|
where
|
|
D: serde::Deserializer<'de>,
|
|
{
|
|
Option::<String>::deserialize(d).map(Some)
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Handlers
|
|
// ----------------------------------------------------------------------------
|
|
|
|
async fn list_scripts<R: ScriptRepository, L: ExecutionLogRepository>(
|
|
State(state): State<AdminState<R, L>>,
|
|
Extension(principal): Extension<Principal>,
|
|
Query(q): Query<ListScriptsQuery>,
|
|
) -> Result<Json<Vec<Script>>, ApiError> {
|
|
// Membership filter: `member` users see only scripts in apps they
|
|
// belong to. `?app=` filters further by app and additionally
|
|
// requires the member to belong to that app (the read check uses
|
|
// the resource's app_id).
|
|
if let Some(ident) = q.app {
|
|
let app = resolve_app_ident(state.apps.as_ref(), &ident).await?;
|
|
require(state.authz.as_ref(), &principal, Capability::AppRead(app)).await?;
|
|
return Ok(Json(state.repo.list_for_app(app).await?));
|
|
}
|
|
if principal.instance_role == InstanceRole::Member {
|
|
return Ok(Json(state.repo.list_for_user(principal.user_id).await?));
|
|
}
|
|
Ok(Json(state.repo.list().await?))
|
|
}
|
|
|
|
/// Accept `?app=<uuid>` OR `?app=<slug>`. Slugs route through history
|
|
/// for redirects, but here we just need the live current id; if a
|
|
/// retired slug is given, we follow it to the current app silently.
|
|
async fn resolve_app_ident(apps: &dyn AppRepository, ident: &str) -> Result<AppId, ApiError> {
|
|
if let Ok(uuid) = ident.parse::<uuid::Uuid>() {
|
|
let id = AppId::from(uuid);
|
|
apps.get_by_id(id)
|
|
.await?
|
|
.ok_or(ApiError::AppNotFound(ident.to_string()))?;
|
|
return Ok(id);
|
|
}
|
|
let lookup = apps
|
|
.get_by_slug_or_history(ident)
|
|
.await?
|
|
.ok_or(ApiError::AppNotFound(ident.to_string()))?;
|
|
Ok(lookup.app.id)
|
|
}
|
|
|
|
/// Capability authorizing an operation on a script of either owner (Phase 4).
|
|
/// App-owned scripts map to their `App*` capability exactly as before; a
|
|
/// group-owned script maps to the group-scoped capability, resolved against
|
|
/// the group ancestor walk. An orphan row (neither owner — impossible under
|
|
/// the DB CHECK) reads as a missing id.
|
|
///
|
|
/// The `app`/`group` arguments are the capability constructors for each
|
|
/// owner, so one helper serves read / write / admin / log-read by passing
|
|
/// the matching pair.
|
|
fn script_cap(
|
|
script: &Script,
|
|
app: fn(AppId) -> Capability,
|
|
group: fn(GroupId) -> Capability,
|
|
) -> Result<Capability, ApiError> {
|
|
match script.owner() {
|
|
Some(ScriptOwner::App(a)) => Ok(app(a)),
|
|
Some(ScriptOwner::Group(g)) => Ok(group(g)),
|
|
None => Err(ApiError::NotFound(script.id)),
|
|
}
|
|
}
|
|
|
|
async fn get_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
|
State(state): State<AdminState<R, L>>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(id): Path<ScriptId>,
|
|
) -> Result<Json<Script>, ApiError> {
|
|
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
|
|
require(
|
|
state.authz.as_ref(),
|
|
&principal,
|
|
script_cap(&script, Capability::AppRead, Capability::GroupScriptsRead)?,
|
|
)
|
|
.await?;
|
|
Ok(Json(script))
|
|
}
|
|
|
|
async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
|
State(state): State<AdminState<R, L>>,
|
|
Extension(principal): Extension<Principal>,
|
|
Json(input): Json<CreateScriptRequest>,
|
|
) -> Result<(StatusCode, Json<Script>), ApiError> {
|
|
// Capability is bound to the *requested* app_id since there's no
|
|
// resource to load yet. If the app doesn't exist we 422 below;
|
|
// checking authz first means a Member trying to create against an
|
|
// unknown app gets 403 (no enumeration of app existence).
|
|
require(
|
|
state.authz.as_ref(),
|
|
&principal,
|
|
Capability::AppWriteScript(input.app_id),
|
|
)
|
|
.await?;
|
|
// v1.1.3: dispatch to the right validator based on declared kind.
|
|
// Module bodies have stricter rules (no top-level statements) so
|
|
// they need a separate gate; endpoints retain the parse-only path.
|
|
let validated: ValidatedScript = if input.kind == ScriptKind::Module {
|
|
if RESERVED_MODULE_NAMES.contains(&input.name.as_str()) {
|
|
return Err(ApiError::Invalid(ValidationError::ModuleShape(format!(
|
|
"{:?} is a reserved module name (shadows a built-in SDK namespace)",
|
|
input.name
|
|
))));
|
|
}
|
|
state.validator.validate_module(&input.source)?
|
|
} else {
|
|
state.validator.validate(&input.source)?
|
|
};
|
|
state.sandbox_ceiling.check(&input.sandbox)?;
|
|
// Refuse early if the app_id doesn't exist — a clean 422 beats a
|
|
// raw FK violation surfacing as 500.
|
|
if state.apps.get_by_id(input.app_id).await?.is_none() {
|
|
return Err(ApiError::AppNotFound(input.app_id.to_string()));
|
|
}
|
|
let created = state
|
|
.repo
|
|
.create(NewScript {
|
|
app_id: Some(input.app_id),
|
|
group_id: None,
|
|
name: input.name,
|
|
description: input.description,
|
|
source: input.source,
|
|
kind: input.kind,
|
|
timeout_seconds: input.timeout_seconds,
|
|
memory_limit_mb: input.memory_limit_mb,
|
|
sandbox: if input.sandbox.is_empty() {
|
|
None
|
|
} else {
|
|
Some(input.sandbox)
|
|
},
|
|
// Scripts are created active; toggling is a dedicated path.
|
|
enabled: true,
|
|
imports: validated.imports,
|
|
})
|
|
.await?;
|
|
Ok((StatusCode::CREATED, Json(created)))
|
|
}
|
|
|
|
/// Module names that would shadow a built-in stdlib / service namespace.
|
|
/// Rejected at create time so `import "kv" as foo` can never resolve to
|
|
/// a user-supplied module instead of (in a hypothetical future) the
|
|
/// real KV bridge — defense against author confusion, not a security
|
|
/// boundary (stdlib namespaces and module imports already live in
|
|
/// disjoint Rhai scopes).
|
|
pub(crate) const RESERVED_MODULE_NAMES: &[&str] = &[
|
|
"log",
|
|
"regex",
|
|
"random",
|
|
"time",
|
|
"json",
|
|
"base64",
|
|
"hex",
|
|
"url",
|
|
"kv",
|
|
"docs",
|
|
"dead_letters",
|
|
"http",
|
|
"files",
|
|
"pubsub",
|
|
"secrets",
|
|
"email",
|
|
"users",
|
|
"queue",
|
|
];
|
|
|
|
async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
|
State(state): State<AdminState<R, L>>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(id): Path<ScriptId>,
|
|
Json(input): Json<UpdateScriptRequest>,
|
|
) -> Result<Json<Script>, ApiError> {
|
|
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
|
|
require(
|
|
state.authz.as_ref(),
|
|
&principal,
|
|
script_cap(
|
|
&script,
|
|
Capability::AppWriteScript,
|
|
Capability::GroupScriptsWrite,
|
|
)?,
|
|
)
|
|
.await?;
|
|
|
|
// Effective post-update kind: explicit override > existing kind.
|
|
let effective_kind = input.kind.unwrap_or(script.kind);
|
|
|
|
// v1.1.3: reject `endpoint → module` if the script still has
|
|
// routes or triggers bound to it. The reverse direction is always
|
|
// allowed (a module can't have routes/triggers anyway, so the
|
|
// transition can never strand users).
|
|
if effective_kind == ScriptKind::Module && script.kind != ScriptKind::Module {
|
|
let routes = state.repo.count_routes_for_script(id).await?;
|
|
let triggers = state.repo.count_triggers_for_script(id).await?;
|
|
if routes + triggers > 0 {
|
|
return Err(ApiError::Invalid(ValidationError::ModuleShape(format!(
|
|
"cannot change kind to module: script is referenced by {routes} route(s) and {triggers} trigger(s); detach them first"
|
|
))));
|
|
}
|
|
if RESERVED_MODULE_NAMES.contains(&script.name.as_str()) {
|
|
return Err(ApiError::Invalid(ValidationError::ModuleShape(format!(
|
|
"{:?} is a reserved module name (shadows a built-in SDK namespace)",
|
|
script.name
|
|
))));
|
|
}
|
|
}
|
|
|
|
// v1.1.3: re-validate using the effective kind so endpoint → module
|
|
// transitions with a fresh source enforce the module shape rules.
|
|
// Source-less edits (name/description only) don't re-validate.
|
|
let imports_for_patch: Option<Vec<String>> = if let Some(src) = input.source.as_deref() {
|
|
let validated = if effective_kind == ScriptKind::Module {
|
|
state.validator.validate_module(src)?
|
|
} else {
|
|
state.validator.validate(src)?
|
|
};
|
|
Some(validated.imports)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
if let Some(sb) = input.sandbox.as_ref() {
|
|
state.sandbox_ceiling.check(sb)?;
|
|
}
|
|
let updated = state
|
|
.repo
|
|
.update(
|
|
id,
|
|
ScriptPatch {
|
|
name: input.name,
|
|
description: input.description,
|
|
source: input.source,
|
|
timeout_seconds: input.timeout_seconds,
|
|
memory_limit_mb: input.memory_limit_mb,
|
|
sandbox: input.sandbox,
|
|
kind: input.kind,
|
|
enabled: None,
|
|
imports: imports_for_patch,
|
|
},
|
|
)
|
|
.await?;
|
|
Ok(Json(updated))
|
|
}
|
|
|
|
async fn delete_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
|
State(state): State<AdminState<R, L>>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(id): Path<ScriptId>,
|
|
) -> Result<StatusCode, ApiError> {
|
|
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
|
|
// Delete is gated tighter than Save for app-owned scripts: editors can
|
|
// edit but only app_admin / instance admin / owner can remove them (§11.6).
|
|
// A group-owned script (Phase 4) has no group-admin-tier script cap, so it
|
|
// is gated at `GroupScriptsWrite` (editor+ on the group) — the same tier
|
|
// that created it; group deletion itself stays group_admin-gated elsewhere.
|
|
require(
|
|
state.authz.as_ref(),
|
|
&principal,
|
|
script_cap(&script, Capability::AppAdmin, Capability::GroupScriptsWrite)?,
|
|
)
|
|
.await?;
|
|
state.repo.delete(id).await?;
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct LogsQuery {
|
|
#[serde(default = "default_limit")]
|
|
pub limit: i64,
|
|
/// F-P-005: keyset cursor (`<rfc3339>_<uuid>`). Replaces the OFFSET
|
|
/// path that scanned + discarded N rows per page. Absent for the
|
|
/// first page. The legacy `offset` query param is accepted but
|
|
/// silently ignored to keep older dashboards from 400'ing.
|
|
#[serde(default)]
|
|
pub cursor: Option<String>,
|
|
/// Legacy field — accepted and ignored so older dashboards don't 400.
|
|
#[serde(default, rename = "offset")]
|
|
#[allow(dead_code)]
|
|
pub legacy_offset: Option<i64>,
|
|
/// Optional origin filter (`http`, `kv`, `cron`, `invoke`, …). Absent
|
|
/// → all sources. An unrecognized value is a 422 (see `list_logs`).
|
|
#[serde(default)]
|
|
pub source: Option<String>,
|
|
}
|
|
|
|
const fn default_limit() -> i64 {
|
|
50
|
|
}
|
|
|
|
async fn list_logs<R: ScriptRepository, L: ExecutionLogRepository>(
|
|
State(state): State<AdminState<R, L>>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(id): Path<ScriptId>,
|
|
axum::extract::Query(q): axum::extract::Query<LogsQuery>,
|
|
) -> Result<Json<Vec<ExecutionLog>>, ApiError> {
|
|
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
|
|
require(
|
|
state.authz.as_ref(),
|
|
&principal,
|
|
script_cap(
|
|
&script,
|
|
Capability::AppLogRead,
|
|
Capability::GroupScriptsRead,
|
|
)?,
|
|
)
|
|
.await?;
|
|
// Cap to keep the dashboard responsive; the data plane writes are
|
|
// unbounded over time so a paged read is the only sane default.
|
|
let limit = q.limit.clamp(1, 200);
|
|
let cursor = q
|
|
.cursor
|
|
.as_deref()
|
|
.and_then(crate::repo::ExecutionLogCursor::decode);
|
|
let source = match q.source.as_deref() {
|
|
None | Some("" | "all") => None,
|
|
Some(s) => Some(
|
|
ExecutionSource::from_wire(s)
|
|
.ok_or_else(|| ApiError::BadRequest(format!("unknown log source: {s:?}")))?,
|
|
),
|
|
};
|
|
let logs = state
|
|
.logs
|
|
.list_for_script(id, limit, cursor, source)
|
|
.await?;
|
|
Ok(Json(logs))
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Errors
|
|
// ----------------------------------------------------------------------------
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum ApiError {
|
|
#[error("script not found: {0}")]
|
|
NotFound(ScriptId),
|
|
|
|
#[error("app not found: {0}")]
|
|
AppNotFound(String),
|
|
|
|
#[error("bad request: {0}")]
|
|
BadRequest(String),
|
|
|
|
#[error("conflict: {0}")]
|
|
Conflict(String),
|
|
|
|
#[error("invalid script: {0}")]
|
|
Invalid(#[from] ValidationError),
|
|
|
|
#[error("{0}")]
|
|
Ceiling(#[from] CeilingError),
|
|
|
|
#[error("forbidden")]
|
|
Forbidden,
|
|
|
|
#[error("authorization repo error: {0}")]
|
|
AuthzRepo(String),
|
|
|
|
#[error("repository error: {0}")]
|
|
Repo(#[from] ScriptRepositoryError),
|
|
}
|
|
|
|
impl From<AuthzDenied> for ApiError {
|
|
fn from(d: AuthzDenied) -> Self {
|
|
match d {
|
|
AuthzDenied::Denied => Self::Forbidden,
|
|
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl IntoResponse for ApiError {
|
|
fn into_response(self) -> Response {
|
|
let (status, message) = match &self {
|
|
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
|
|
Self::AppNotFound(_) | Self::BadRequest(_) | Self::Invalid(_) | Self::Ceiling(_) => {
|
|
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
|
|
}
|
|
Self::Conflict(_) => (StatusCode::CONFLICT, self.to_string()),
|
|
Self::Forbidden => (StatusCode::FORBIDDEN, self.to_string()),
|
|
Self::AuthzRepo(e) => {
|
|
tracing::error!(error = %e, "authz repo error");
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
"internal error".to_string(),
|
|
)
|
|
}
|
|
Self::Repo(ScriptRepositoryError::NotFound(_)) => {
|
|
(StatusCode::NOT_FOUND, self.to_string())
|
|
}
|
|
Self::Repo(ScriptRepositoryError::Conflict(_)) => {
|
|
(StatusCode::CONFLICT, self.to_string())
|
|
}
|
|
Self::Repo(ScriptRepositoryError::Db(e)) => {
|
|
tracing::error!(error = %e, "manager db error");
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
"internal error".to_string(),
|
|
)
|
|
}
|
|
};
|
|
(status, Json(serde_json::json!({ "error": message }))).into_response()
|
|
}
|
|
}
|