//! §9.4 Service-interceptor markers — the `interceptors` table (0073). //! //! A marker `(owner, service, op) -> interceptor_script` declares a before-op //! allow/deny hook. Pure declaration (like `extension_points`, 0051); the //! interceptor's behaviour is a normal script resolved + run through `invoke()` //! re-entry. This module holds the read + transactional-write helpers (free //! functions over `&PgPool` / `&mut Transaction`, keyed by [`ScriptOwner`]), //! plus the runtime [`resolve_chain`] walk (all markers on the app's chain). use chrono::{DateTime, Utc}; use picloud_shared::{AppId, ScriptOwner}; use sqlx::{PgPool, Postgres, Transaction}; use uuid::Uuid; use crate::config_resolver::CHAIN_LEVELS_CTE; /// One interceptor marker at an owner: which `(service, op)` it guards and the /// interceptor script name. #[derive(Debug, Clone, PartialEq, Eq)] pub struct InterceptorMarker { pub service: String, pub op: String, /// `before` (allow/deny + transform) or `after` (observe; §9.4 M3). pub phase: String, pub script: String, /// §9.4 M5: per-hook timeout, or `None` for the instance default. pub timeout_ms: Option, } /// List the markers declared **directly at** `owner` (not inherited), ordered /// deterministically. Used by `load_current` (apply diff) and `interceptors ls`. pub async fn list_for_owner( pool: &PgPool, owner: ScriptOwner, ) -> Result, sqlx::Error> { let rows: Vec<(String, String, String, String, Option)> = match owner { ScriptOwner::App(a) => { sqlx::query_as( "SELECT service, op, phase, interceptor_script, timeout_ms FROM interceptors \ WHERE app_id = $1 ORDER BY service, op, phase", ) .bind(a.into_inner()) .fetch_all(pool) .await? } ScriptOwner::Group(g) => { sqlx::query_as( "SELECT service, op, phase, interceptor_script, timeout_ms FROM interceptors \ WHERE group_id = $1 ORDER BY service, op, phase", ) .bind(g.into_inner()) .fetch_all(pool) .await? } }; Ok(rows .into_iter() .map( |(service, op, phase, script, timeout_ms)| InterceptorMarker { service, op, phase, script, timeout_ms, }, ) .collect()) } /// All markers **visible to an app** — declared at the app or any ancestor /// group (each `(service, op)` resolved nearest-owner-wins). Used by /// `interceptors ls --app` and by the resolver's chain view. pub async fn list_on_app_chain( pool: &PgPool, app_id: AppId, ) -> Result, sqlx::Error> { let rows: Vec<(String, String, String, String, Option)> = sqlx::query_as(&format!( "{CHAIN_LEVELS_CTE} \ SELECT DISTINCT ON (i.service, i.op, i.phase) \ i.service, i.op, i.phase, i.interceptor_script, i.timeout_ms \ FROM chain c \ JOIN interceptors i ON (i.app_id = c.app_owner OR i.group_id = c.group_owner) \ ORDER BY i.service, i.op, i.phase, c.depth ASC", )) .bind(app_id.into_inner()) .fetch_all(pool) .await?; Ok(rows .into_iter() .map( |(service, op, phase, script, timeout_ms)| InterceptorMarker { service, op, phase, script, timeout_ms, }, ) .collect()) } /// The nearest interceptor marker on an app's chain, with its script resolved /// **at the declaring owner** (the seal). `script` is `None` when the marker /// names a script that is missing or disabled at that owner — a dangling hook /// the caller must fail closed on. #[derive(Debug, Clone)] pub struct SealedInterceptor { pub marker_app: Option, pub marker_group: Option, pub script_name: String, /// §9.4 M5: per-hook wall-clock timeout, or `None` for the instance default. pub timeout_ms: Option, /// `(script_id, source, updated_at)` of the sealed script, or `None` if it /// is missing/disabled at the declaring owner. pub script: Option<(Uuid, String, DateTime)>, } impl SealedInterceptor { /// The owner that DECLARED the marker (and therefore owns the resolved /// script) — the lexical origin for the interceptor's own `import`s (§5.5). #[must_use] pub fn sealing_owner(&self) -> ScriptOwner { if let Some(g) = self.marker_group { ScriptOwner::Group(g.into()) } else { ScriptOwner::App( self.marker_app .expect("marker XOR: app when not group") .into(), ) } } } /// The ordered before + after interceptors guarding a `(service, op)` for a /// calling app, each sealed to its declaring owner (see [`SealedInterceptor`]). #[derive(Debug, Clone, Default)] pub struct SealedChain { /// Ordered ancestor→app (outermost/group guard first). pub before: Vec, /// Ordered app→ancestor. pub after: Vec, } /// Resolve ALL interceptor markers guarding `(service, op)` for a calling app — /// every marker visible on the app's chain (app + each ancestor group), each /// with its script **sealed to the owner that declared the marker** (a /// descendant app cannot shadow it with a same-named local script). Split by /// `phase` and ordered: `before` ancestor→app (an outer/group guard runs first, /// so a group denial cannot be bypassed by a descendant), `after` app→ancestor. /// **This chain walk is the resolution boundary** — a sibling-subtree app never /// sees another subtree's interceptor. The script join is `LEFT` so a marker /// whose script is missing/disabled still returns an entry (with `script: /// None`) the caller fails closed on, rather than being silently dropped. /// /// Per the `(owner, service, op, phase)` partial-unique index there is at most /// one marker per owner per phase, so no per-owner dedup is needed. pub async fn resolve_chain( pool: &PgPool, app_id: AppId, service: &str, op: &str, ) -> Result { #[allow(clippy::type_complexity)] let rows: Vec<( Option, // marker_app Option, // marker_group String, // script_name String, // phase Option, // timeout_ms i32, // depth (0 = app, larger = ancestor) Option, // script id Option, // script source Option>, // script updated_at )> = sqlx::query_as(&format!( "{CHAIN_LEVELS_CTE}, \ markers AS ( \ SELECT i.interceptor_script AS script_name, \ i.app_id AS marker_app, i.group_id AS marker_group, \ i.phase AS phase, i.timeout_ms AS timeout_ms, c.depth AS depth \ FROM chain c \ JOIN interceptors i ON (i.app_id = c.app_owner OR i.group_id = c.group_owner) \ WHERE i.service = $2 AND i.op = $3 \ ) \ SELECT m.marker_app, m.marker_group, m.script_name, m.phase, m.timeout_ms, m.depth, \ s.id, s.source, s.updated_at \ FROM markers m \ LEFT JOIN scripts s ON ( \ (m.marker_app IS NOT NULL AND s.app_id = m.marker_app \ AND s.name = m.script_name AND s.enabled) \ OR (m.marker_group IS NOT NULL AND s.group_id = m.marker_group \ AND s.name = m.script_name AND s.enabled) \ )", )) .bind(app_id.into_inner()) .bind(service) .bind(op) .fetch_all(pool) .await?; // Split by phase and order: `before` ancestor→app = depth DESC (larger // depth = ancestor runs first); `after` app→ancestor = depth ASC. let mut before: Vec<(i32, SealedInterceptor)> = Vec::new(); let mut after: Vec<(i32, SealedInterceptor)> = Vec::new(); for (marker_app, marker_group, script_name, phase, timeout_ms, depth, sid, src, upd) in rows { let sealed = SealedInterceptor { marker_app, marker_group, script_name, timeout_ms, script: match (sid, src, upd) { (Some(id), Some(source), Some(updated_at)) => Some((id, source, updated_at)), _ => None, }, }; if phase == "after" { after.push((depth, sealed)); } else { before.push((depth, sealed)); } } before.sort_by(|a, b| b.0.cmp(&a.0)); // depth DESC → ancestor first after.sort_by(|a, b| a.0.cmp(&b.0)); // depth ASC → app first Ok(SealedChain { before: before.into_iter().map(|(_, s)| s).collect(), after: after.into_iter().map(|(_, s)| s).collect(), }) } /// Upsert a marker at `owner` in the apply transaction. A re-apply that changes /// only the interceptor script updates it in place (no version churn on the /// `(service, op)` identity). pub async fn insert_interceptor_tx( tx: &mut Transaction<'_, Postgres>, owner: ScriptOwner, service: &str, op: &str, phase: &str, script: &str, timeout_ms: Option, ) -> Result<(), sqlx::Error> { match owner { ScriptOwner::App(a) => { sqlx::query( // The conflict arbiter includes `phase` to match the // per-(owner, service, op, phase) index (§9.4 M3). A re-apply // that changes only the script or timeout updates in place. "INSERT INTO interceptors (app_id, service, op, phase, interceptor_script, timeout_ms) \ VALUES ($1, $2, $3, $4, $5, $6) \ ON CONFLICT (app_id, service, op, phase) WHERE app_id IS NOT NULL \ DO UPDATE SET interceptor_script = EXCLUDED.interceptor_script, \ timeout_ms = EXCLUDED.timeout_ms, updated_at = NOW()", ) .bind(a.into_inner()) .bind(service) .bind(op) .bind(phase) .bind(script) .bind(timeout_ms) .execute(&mut **tx) .await?; } ScriptOwner::Group(g) => { sqlx::query( "INSERT INTO interceptors (group_id, service, op, phase, interceptor_script, timeout_ms) \ VALUES ($1, $2, $3, $4, $5, $6) \ ON CONFLICT (group_id, service, op, phase) WHERE group_id IS NOT NULL \ DO UPDATE SET interceptor_script = EXCLUDED.interceptor_script, \ timeout_ms = EXCLUDED.timeout_ms, updated_at = NOW()", ) .bind(g.into_inner()) .bind(service) .bind(op) .bind(phase) .bind(script) .bind(timeout_ms) .execute(&mut **tx) .await?; } } Ok(()) } /// Delete a marker at `owner` (by `(service, op, phase)`), in the apply /// transaction. Used by `--prune` when the manifest stops declaring it. pub async fn delete_interceptor_tx( tx: &mut Transaction<'_, Postgres>, owner: ScriptOwner, service: &str, op: &str, phase: &str, ) -> Result<(), sqlx::Error> { match owner { ScriptOwner::App(a) => { sqlx::query( "DELETE FROM interceptors \ WHERE app_id = $1 AND service = $2 AND op = $3 AND phase = $4", ) .bind(a.into_inner()) .bind(service) .bind(op) .bind(phase) .execute(&mut **tx) .await?; } ScriptOwner::Group(g) => { sqlx::query( "DELETE FROM interceptors \ WHERE group_id = $1 AND service = $2 AND op = $3 AND phase = $4", ) .bind(g.into_inner()) .bind(service) .bind(op) .bind(phase) .execute(&mut **tx) .await?; } } Ok(()) }