From 9601046e29dfc8f0c561952aa463a674b61697f8 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Wed, 1 Jul 2026 08:02:04 +0200 Subject: [PATCH] =?UTF-8?q?feat(suppress):=20pic=20suppress=20ls=20--app?= =?UTF-8?q?=20+=20dangling-suppress=20warning=20(=C2=A711=20tail=20S4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Visibility + typo guard. - `pic suppress ls --app ` — read-only (kind, reference): ApplyService::suppression_report(App) → GET /apps/{id}/suppressions (viewer-tier AppRead), client + cmd + main.rs wiring. - Dangling-suppress warning: at apply, a suppress reference that matches no inherited template on the app's chain (no ancestor-group trigger bound to that script name / no ancestor-group route at that path) emits an ApplyReport warning — the suppression silently does nothing otherwise. Soft (never fails the apply), reusing the existing warnings channel. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/manager-core/src/apply_api.rs | 21 ++++- crates/manager-core/src/apply_service.rs | 98 +++++++++++++++++++++++- crates/picloud-cli/src/client.rs | 23 ++++++ crates/picloud-cli/src/cmds/mod.rs | 1 + crates/picloud-cli/src/cmds/suppress.rs | 23 ++++++ crates/picloud-cli/src/main.rs | 20 +++++ 6 files changed, 183 insertions(+), 3 deletions(-) create mode 100644 crates/picloud-cli/src/cmds/suppress.rs diff --git a/crates/manager-core/src/apply_api.rs b/crates/manager-core/src/apply_api.rs index 3d6dd43..bbde551 100644 --- a/crates/manager-core/src/apply_api.rs +++ b/crates/manager-core/src/apply_api.rs @@ -18,8 +18,8 @@ use serde_json::json; use crate::app_repo::AppRepository; use crate::apply_service::{ ApplyError, ApplyOwner, ApplyReport, ApplyService, Bundle, BundleTrigger, CollectionInfo, - ExtensionPointInfo, NodeKind, PlanResult, RouteTemplateInfo, TreeBundle, TreePlanResult, - TriggerTemplateInfo, + ExtensionPointInfo, NodeKind, PlanResult, RouteTemplateInfo, SuppressionInfo, TreeBundle, + TreePlanResult, TriggerTemplateInfo, }; use crate::authz::{require, AuthzDenied, Capability}; use crate::group_repo::GroupRepository; @@ -44,9 +44,26 @@ pub fn apply_router(service: ApplyService) -> Router { .route("/groups/{id}/collections", get(group_collections_handler)) .route("/groups/{id}/triggers", get(group_triggers_handler)) .route("/groups/{id}/routes", get(group_routes_handler)) + .route("/apps/{id}/suppressions", get(app_suppressions_handler)) .with_state(service) } +/// Read-only §11 tail suppression report for an app: the inherited templates it +/// declines (`target_kind`, `reference`). Viewer-tier `AppRead`. Backs +/// `pic suppress ls --app`. +async fn app_suppressions_handler( + State(svc): State, + Extension(principal): Extension, + Path(id_or_slug): Path, +) -> Result>, ApplyError> { + let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?; + require(svc.authz.as_ref(), &principal, Capability::AppRead(app_id)) + .await + .map_err(map_authz)?; + let report = svc.suppression_report(ApplyOwner::App(app_id)).await?; + Ok(Json(report)) +} + /// Read-only §11 tail route-template report for a group: its own declared route /// templates (method, host, path, handler script, dispatch, enabled). Viewer- /// tier read. Backs `pic routes ls --group`. diff --git a/crates/manager-core/src/apply_service.rs b/crates/manager-core/src/apply_service.rs index 24c8f30..60fe2f9 100644 --- a/crates/manager-core/src/apply_service.rs +++ b/crates/manager-core/src/apply_service.rs @@ -41,6 +41,7 @@ use sqlx::PgPool; use crate::app_domain_repo::AppDomainRepository; use crate::app_repo::AppRepository; use crate::authz::AuthzRepo; +use crate::config_resolver::CHAIN_LEVELS_CTE; use crate::repo::{ delete_script_tx, insert_script_tx, update_script_tx, NewScript, ScriptPatch, ScriptRepository, ScriptRepositoryError, @@ -475,6 +476,15 @@ pub struct CollectionInfo { pub kind: String, } +/// One row of the read-only §11 tail suppression report (`pic suppress ls +/// --app`). `target_kind` is `trigger` | `route`; `reference` is the handler +/// script name (trigger) or path (route) the app declines. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SuppressionInfo { + pub target_kind: String, + pub reference: String, +} + /// One row of the read-only §11 tail trigger-template report (`pic triggers ls /// --group`). `target` is the kind-specific identity bit (collection glob or /// topic pattern); `script` is the handler script's name. @@ -1220,10 +1230,15 @@ impl ApplyService { // A group endpoint is reached by *inheritance* (a descendant app binds // it by name), never by its own route, so the "no route/trigger" // warning is noise for every group script — emit it for apps only. - if matches!(owner, ApplyOwner::App(_)) { + if let ApplyOwner::App(app_id) = owner { report .warnings .extend(unreachable_endpoint_warnings(bundle)); + // §11 tail: a suppress reference that matches no inherited template + // silently does nothing — warn (typo guard), don't fail. + report + .warnings + .extend(self.dangling_suppress_warnings(app_id, bundle).await?); } self.reconcile_node_tx( @@ -2064,6 +2079,87 @@ impl ApplyService { .collect()) } + /// §11 tail typo guard: a suppress reference that matches **no** inherited + /// template on the app's chain does nothing — return a warning for each such + /// dangling reference (soft; never fails the apply). Trigger refs match an + /// ancestor-group trigger's handler script name (case-insensitive); route + /// refs match an ancestor-group route's path. + async fn dangling_suppress_warnings( + &self, + app_id: AppId, + bundle: &Bundle, + ) -> Result, ApplyError> { + if bundle.suppress_triggers.is_empty() && bundle.suppress_routes.is_empty() { + return Ok(Vec::new()); + } + // Inherited (ancestor-group) trigger handler names + route paths. + let trig_names: Vec<(String,)> = sqlx::query_as(&format!( + "{CHAIN_LEVELS_CTE} \ + SELECT DISTINCT LOWER(s.name) FROM triggers t \ + JOIN scripts s ON s.id = t.script_id \ + JOIN chain c ON t.group_id = c.group_owner \ + WHERE t.group_id IS NOT NULL", + )) + .bind(app_id.into_inner()) + .fetch_all(&self.pool) + .await + .map_err(|e| ApplyError::Backend(e.to_string()))?; + let inherited_handlers: HashSet = trig_names.into_iter().map(|(n,)| n).collect(); + + let route_paths: Vec<(String,)> = sqlx::query_as(&format!( + "{CHAIN_LEVELS_CTE} \ + SELECT DISTINCT r.path FROM routes r \ + JOIN chain c ON r.group_id = c.group_owner \ + WHERE r.group_id IS NOT NULL", + )) + .bind(app_id.into_inner()) + .fetch_all(&self.pool) + .await + .map_err(|e| ApplyError::Backend(e.to_string()))?; + let inherited_paths: HashSet = route_paths.into_iter().map(|(p,)| p).collect(); + + let mut out = Vec::new(); + for r in &bundle.suppress_triggers { + if !inherited_handlers.contains(&r.to_lowercase()) { + out.push(format!( + "suppress: no inherited trigger bound to script `{r}` — the \ + suppression has no effect" + )); + } + } + for r in &bundle.suppress_routes { + if !inherited_paths.contains(r) { + out.push(format!( + "suppress: no inherited route at path `{r}` — the \ + suppression has no effect" + )); + } + } + Ok(out) + } + + /// Read-only §11 tail report: an app's own suppression markers (the + /// inherited templates it declines). Group owner → empty (groups don't + /// suppress). Backs `pic suppress ls --app`. + pub async fn suppression_report( + &self, + owner: ApplyOwner, + ) -> Result, ApplyError> { + let ApplyOwner::App(app_id) = owner else { + return Ok(Vec::new()); + }; + let rows = crate::suppression_repo::list_for_app(&self.pool, app_id) + .await + .map_err(|e| ApplyError::Backend(e.to_string()))?; + Ok(rows + .into_iter() + .map(|(target_kind, reference)| SuppressionInfo { + target_kind, + reference, + }) + .collect()) + } + /// §5.5 no-provider plan check (app nodes only). Every extension point /// visible to the app — declared in this bundle or inherited from an /// ancestor — must have a provider: a module of that name the app provides diff --git a/crates/picloud-cli/src/client.rs b/crates/picloud-cli/src/client.rs index adb03c6..faf3e80 100644 --- a/crates/picloud-cli/src/client.rs +++ b/crates/picloud-cli/src/client.rs @@ -1371,6 +1371,29 @@ impl Client { .await?; decode(resp).await } + + /// `GET /api/v1/admin/apps/{ident}/suppressions` (§11 tail). App-only — the + /// inherited templates the app declines. + pub async fn suppressions_list(&self, app_ident: &str) -> Result> { + let ident = seg(app_ident); + let resp = self + .request( + Method::GET, + &format!("/api/v1/admin/apps/{ident}/suppressions"), + ) + .send() + .await?; + decode(resp).await + } +} + +/// One row of the §11 tail suppression report. +#[derive(Debug, Deserialize)] +pub struct SuppressionDto { + #[serde(default)] + pub target_kind: String, + #[serde(default)] + pub reference: String, } /// One row of the §11 tail trigger-template report. diff --git a/crates/picloud-cli/src/cmds/mod.rs b/crates/picloud-cli/src/cmds/mod.rs index 871e032..b9993f0 100644 --- a/crates/picloud-cli/src/cmds/mod.rs +++ b/crates/picloud-cli/src/cmds/mod.rs @@ -21,6 +21,7 @@ pub mod queues; pub mod routes; pub mod scripts; pub mod secrets; +pub mod suppress; pub mod topics; pub mod triggers; pub mod users; diff --git a/crates/picloud-cli/src/cmds/suppress.rs b/crates/picloud-cli/src/cmds/suppress.rs new file mode 100644 index 0000000..8086e7d --- /dev/null +++ b/crates/picloud-cli/src/cmds/suppress.rs @@ -0,0 +1,23 @@ +//! `pic suppress ls --app ` — read-only view of an app's §11 tail +//! per-app opt-outs: the inherited group templates it declines. App-only; +//! authoring is declarative via the `[suppress]` block (`triggers = [...]` +//! handler script names, `routes = [...]` paths). + +use anyhow::Result; + +use crate::client::Client; +use crate::config; +use crate::output::{OutputMode, Table}; + +pub async fn ls(app: &str, mode: OutputMode) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + let items = client.suppressions_list(app).await?; + + let mut table = Table::new(["kind", "reference"]); + for s in &items { + table.row([s.target_kind.clone(), s.reference.clone()]); + } + table.print(mode); + Ok(()) +} diff --git a/crates/picloud-cli/src/main.rs b/crates/picloud-cli/src/main.rs index 045831b..a415d7e 100644 --- a/crates/picloud-cli/src/main.rs +++ b/crates/picloud-cli/src/main.rs @@ -171,6 +171,14 @@ enum Cmd { cmd: CollectionsCmd, }, + /// Per-app opt-out of inherited group templates (§11 tail). Authoring is + /// declarative via the `[suppress]` manifest block; this lists what an app + /// declines. + Suppress { + #[command(subcommand)] + cmd: SuppressCmd, + }, + /// Files inspection — list a collection's blobs, download bytes, or /// delete a file. Read + delete only; writes go through scripts. Files { @@ -577,6 +585,15 @@ enum CollectionsCmd { }, } +#[derive(Subcommand)] +enum SuppressCmd { + /// List the inherited templates an app declines (§11 tail). App-only. + Ls { + #[arg(long)] + app: String, + }, +} + #[derive(Subcommand)] enum ScriptsCmd { /// List scripts. With `--app`, scoped to one app; with `--group`, a @@ -2024,6 +2041,9 @@ async fn main() -> ExitCode { Cmd::Collections { cmd: CollectionsCmd::Ls { group }, } => cmds::collections::ls(&group, mode).await, + Cmd::Suppress { + cmd: SuppressCmd::Ls { app }, + } => cmds::suppress::ls(&app, mode).await, Cmd::Files { cmd: FilesCmd::Ls {