//! §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_before`] chain walk (nearest-owner-wins). 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, pub script: String, } /// 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)> = match owner { ScriptOwner::App(a) => { sqlx::query_as( "SELECT service, op, interceptor_script FROM interceptors \ WHERE app_id = $1 ORDER BY service, op", ) .bind(a.into_inner()) .fetch_all(pool) .await? } ScriptOwner::Group(g) => { sqlx::query_as( "SELECT service, op, interceptor_script FROM interceptors \ WHERE group_id = $1 ORDER BY service, op", ) .bind(g.into_inner()) .fetch_all(pool) .await? } }; Ok(rows .into_iter() .map(|(service, op, script)| InterceptorMarker { service, op, script, }) .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)> = sqlx::query_as(&format!( "{CHAIN_LEVELS_CTE} \ SELECT DISTINCT ON (i.service, i.op) i.service, i.op, i.interceptor_script \ 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, c.depth ASC", )) .bind(app_id.into_inner()) .fetch_all(pool) .await?; Ok(rows .into_iter() .map(|(service, op, script)| InterceptorMarker { service, op, script, }) .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, /// `(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(), ) } } } /// Resolve the interceptor guarding `(service, op)` for a calling app — the /// nearest MARKER on the app's chain (app, then nearest ancestor group), with /// its script **sealed to the owner that declared the marker** (a descendant /// app cannot shadow it with a same-named local script). `None` when un-hooked. /// **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 a row (with `script: None`) /// rather than silently falling through to a farther, weaker marker. pub async fn resolve_before( pool: &PgPool, app_id: AppId, service: &str, op: &str, ) -> Result, sqlx::Error> { #[allow(clippy::type_complexity)] let row: Option<( Option, Option, String, Option, Option, Option>, )> = sqlx::query_as(&format!( "{CHAIN_LEVELS_CTE}, \ nearest AS ( \ SELECT i.interceptor_script AS script_name, \ i.app_id AS marker_app, i.group_id AS marker_group, 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 \ ORDER BY c.depth ASC LIMIT 1 \ ) \ SELECT n.marker_app, n.marker_group, n.script_name, \ s.id, s.source, s.updated_at \ FROM nearest n \ LEFT JOIN scripts s ON ( \ (n.marker_app IS NOT NULL AND s.app_id = n.marker_app \ AND s.name = n.script_name AND s.enabled) \ OR (n.marker_group IS NOT NULL AND s.group_id = n.marker_group \ AND s.name = n.script_name AND s.enabled) \ )", )) .bind(app_id.into_inner()) .bind(service) .bind(op) .fetch_optional(pool) .await?; Ok(row.map( |(marker_app, marker_group, script_name, sid, src, upd)| SealedInterceptor { marker_app, marker_group, script_name, script: match (sid, src, upd) { (Some(id), Some(source), Some(updated_at)) => Some((id, source, updated_at)), _ => None, }, }, )) } /// 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, script: &str, ) -> Result<(), sqlx::Error> { match owner { ScriptOwner::App(a) => { sqlx::query( "INSERT INTO interceptors (app_id, service, op, interceptor_script) \ VALUES ($1, $2, $3, $4) \ ON CONFLICT (app_id, service, op) WHERE app_id IS NOT NULL \ DO UPDATE SET interceptor_script = EXCLUDED.interceptor_script, updated_at = NOW()", ) .bind(a.into_inner()) .bind(service) .bind(op) .bind(script) .execute(&mut **tx) .await?; } ScriptOwner::Group(g) => { sqlx::query( "INSERT INTO interceptors (group_id, service, op, interceptor_script) \ VALUES ($1, $2, $3, $4) \ ON CONFLICT (group_id, service, op) WHERE group_id IS NOT NULL \ DO UPDATE SET interceptor_script = EXCLUDED.interceptor_script, updated_at = NOW()", ) .bind(g.into_inner()) .bind(service) .bind(op) .bind(script) .execute(&mut **tx) .await?; } } Ok(()) } /// Delete a marker at `owner` (by `(service, op)`), 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, ) -> Result<(), sqlx::Error> { match owner { ScriptOwner::App(a) => { sqlx::query("DELETE FROM interceptors WHERE app_id = $1 AND service = $2 AND op = $3") .bind(a.into_inner()) .bind(service) .bind(op) .execute(&mut **tx) .await?; } ScriptOwner::Group(g) => { sqlx::query( "DELETE FROM interceptors WHERE group_id = $1 AND service = $2 AND op = $3", ) .bind(g.into_inner()) .bind(service) .bind(op) .execute(&mut **tx) .await?; } } Ok(()) }