feat(interceptors): hook set_if (CAS) + pic interceptors ls (§9.4 M12)

Closes the §9.4 track:
- set_if (compare-and-swap), per-app and shared, now runs the same (kv, set)
  before/after interceptor as set — otherwise CAS was a silent bypass of a set
  policy. The before-hook can transform the new value; the after-hook sees the
  swapped bool.
- read-only pic interceptors ls --app|--group: app shows the RESOLVED chain
  (every marker guarding its writes, nearest-owner-wins), group its own markers.
  New apply_service::interceptor_report + InterceptorInfo, /apps|groups/{id}/
  interceptors routes (AppRead/GroupScriptsRead), client + cmd mirroring
  extension-points.

CLAUDE.md updated: §9.4 service interceptors are now COMPLETE (M1-M12).
Pinned by a journey: set_if of a guarded key is denied while a free key swaps,
and interceptors ls lists the kv/set guard (12 interceptor journeys green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-16 20:44:42 +02:00
parent ef16d48a8c
commit 0dd03af0c6
9 changed files with 289 additions and 14 deletions

File diff suppressed because one or more lines are too long

View File

@@ -215,12 +215,38 @@ fn register_set_if(engine: &mut RhaiEngine) {
expected: Dynamic, expected: Dynamic,
new: Dynamic| new: Dynamic|
-> Result<bool, Box<EvalAltResult>> { -> Result<bool, Box<EvalAltResult>> {
let h = handle.clone();
let exp = expected_from_dynamic(&expected)?; let exp = expected_from_dynamic(&expected)?;
let new = dynamic_to_json_capped(&new, MAX_JSON_MATERIALIZE_BYTES)?; let json = dynamic_to_json_capped(&new, MAX_JSON_MATERIALIZE_BYTES)?;
block_on("kv", async move { // §9.4 M12: CAS is a WRITE, so it runs the `(kv, set)` guard too —
h.service.set_if(&h.cx, &h.collection, key, exp, new).await // otherwise `set_if` would be a silent bypass of a `set` policy.
}) let write_val = super::interceptor::run_before(
&handle.ictx,
&handle.cx,
"kv",
"set",
&handle.collection,
key,
Some(&json),
)?
.unwrap_or(json);
let written = write_val.clone();
let h = handle.clone();
let swapped = block_on("kv", async move {
h.service
.set_if(&h.cx, &h.collection, key, exp, write_val)
.await
})?;
super::interceptor::run_after(
&handle.ictx,
&handle.cx,
"kv",
"set",
&handle.collection,
key,
Some(&written),
serde_json::Value::Bool(swapped),
)?;
Ok(swapped)
}, },
); );
} }
@@ -395,12 +421,25 @@ fn register_group_set_if(engine: &mut RhaiEngine) {
expected: Dynamic, expected: Dynamic,
new: Dynamic| new: Dynamic|
-> Result<bool, Box<EvalAltResult>> { -> Result<bool, Box<EvalAltResult>> {
let h = handle.clone();
let exp = expected_from_dynamic(&expected)?; let exp = expected_from_dynamic(&expected)?;
let new = dynamic_to_json_capped(&new, MAX_JSON_MATERIALIZE_BYTES)?; let json = dynamic_to_json_capped(&new, MAX_JSON_MATERIALIZE_BYTES)?;
block_on("kv", async move { // §9.4 M12: CAS on a shared collection runs the `(kv, set)` guard too.
h.service.set_if(&h.cx, &h.collection, key, exp, new).await let write_val = group_run_before(handle, "set", key, Some(&json))?.unwrap_or(json);
}) let written = write_val.clone();
let h = handle.clone();
let swapped = block_on("kv", async move {
h.service
.set_if(&h.cx, &h.collection, key, exp, write_val)
.await
})?;
group_run_after(
handle,
"set",
key,
Some(&written),
serde_json::Value::Bool(swapped),
)?;
Ok(swapped)
}, },
); );
} }

View File

@@ -18,8 +18,9 @@ use serde_json::json;
use crate::app_repo::AppRepository; use crate::app_repo::AppRepository;
use crate::apply_service::{ use crate::apply_service::{
ApplyError, ApplyOwner, ApplyReport, ApplyService, Bundle, BundleTrigger, CollectionInfo, ApplyError, ApplyOwner, ApplyReport, ApplyService, Bundle, BundleTrigger, CollectionInfo,
ExtensionPointInfo, NodeKind, OwnershipClaim, PlanResult, ProjectDecl, RouteTemplateInfo, ExtensionPointInfo, InterceptorInfo, NodeKind, OwnershipClaim, PlanResult, ProjectDecl,
StructureMode, SuppressionInfo, TreeBundle, TreePlanResult, TriggerTemplateInfo, RouteTemplateInfo, StructureMode, SuppressionInfo, TreeBundle, TreePlanResult,
TriggerTemplateInfo,
}; };
use crate::authz::{require, AuthzDenied, Capability}; use crate::authz::{require, AuthzDenied, Capability};
use crate::group_repo::GroupRepository; use crate::group_repo::GroupRepository;
@@ -46,9 +47,45 @@ pub fn apply_router(service: ApplyService) -> Router {
.route("/groups/{id}/routes", get(group_routes_handler)) .route("/groups/{id}/routes", get(group_routes_handler))
.route("/apps/{id}/suppressions", get(app_suppressions_handler)) .route("/apps/{id}/suppressions", get(app_suppressions_handler))
.route("/groups/{id}/suppressions", get(group_suppressions_handler)) .route("/groups/{id}/suppressions", get(group_suppressions_handler))
.route("/apps/{id}/interceptors", get(app_interceptors_handler))
.route("/groups/{id}/interceptors", get(group_interceptors_handler))
.with_state(service) .with_state(service)
} }
/// Read-only §9.4 interceptor report for an app: the RESOLVED chain view (every
/// marker guarding its writes, nearest-owner-wins). Viewer-tier `AppRead`. Backs
/// `pic interceptors ls --app`.
async fn app_interceptors_handler(
State(svc): State<ApplyService>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<InterceptorInfo>>, 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.interceptor_report(ApplyOwner::App(app_id)).await?;
Ok(Json(report))
}
/// Read-only §9.4 interceptor report for a group: its OWN declared markers.
async fn group_interceptors_handler(
State(svc): State<ApplyService>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<InterceptorInfo>>, ApplyError> {
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
require(
svc.authz.as_ref(),
&principal,
Capability::GroupScriptsRead(group_id),
)
.await
.map_err(map_authz)?;
let report = svc.interceptor_report(ApplyOwner::Group(group_id)).await?;
Ok(Json(report))
}
/// Read-only §11 tail suppression report for an app: the inherited templates it /// Read-only §11 tail suppression report for an app: the inherited templates it
/// declines (`target_kind`, `reference`). Viewer-tier `AppRead`. Backs /// declines (`target_kind`, `reference`). Viewer-tier `AppRead`. Backs
/// `pic suppress ls --app`. /// `pic suppress ls --app`.

View File

@@ -854,6 +854,19 @@ pub struct CollectionInfo {
pub kind: String, pub kind: String,
} }
/// One row of the read-only §9.4 interceptor report (`pic interceptors ls`).
/// For an app it is the RESOLVED chain view (markers visible on its ancestor
/// chain, nearest-owner-wins); for a group it is the group's OWN declarations.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InterceptorInfo {
pub service: String,
pub op: String,
pub phase: String,
pub script: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_ms: Option<i32>,
}
/// One row of the read-only §11 tail suppression report (`pic suppress ls /// One row of the read-only §11 tail suppression report (`pic suppress ls
/// --app`). `target_kind` is `trigger` | `route`; `reference` is the handler /// --app`). `target_kind` is `trigger` | `route`; `reference` is the handler
/// script name (trigger) or path (route) the app declines. /// script name (trigger) or path (route) the app declines.
@@ -3671,6 +3684,32 @@ impl ApplyService {
/// `inherited default` / `null` = unset). For a **group**: its own declared /// `inherited default` / `null` = unset). For a **group**: its own declared
/// names (no single app to resolve a provider against). Backs the read-only /// names (no single app to resolve a provider against). Backs the read-only
/// `extension-points ls` and the `pull` round-trip. /// `extension-points ls` and the `pull` round-trip.
/// §9.4 read-only interceptor report. For an app: the RESOLVED chain view
/// (every marker visible on its ancestor chain, nearest-owner-wins) — what
/// actually guards its writes. For a group: its OWN declared markers.
pub async fn interceptor_report(
&self,
owner: ApplyOwner,
) -> Result<Vec<InterceptorInfo>, ApplyError> {
let markers = match owner {
ApplyOwner::App(a) => crate::interceptor_repo::list_on_app_chain(&self.pool, a).await,
ApplyOwner::Group(g) => {
crate::interceptor_repo::list_for_owner(&self.pool, ScriptOwner::Group(g)).await
}
}
.map_err(|e| ApplyError::Backend(e.to_string()))?;
Ok(markers
.into_iter()
.map(|m| InterceptorInfo {
service: m.service,
op: m.op,
phase: m.phase,
script: m.script,
timeout_ms: m.timeout_ms,
})
.collect())
}
pub async fn extension_point_report( pub async fn extension_point_report(
&self, &self,
owner: ApplyOwner, owner: ApplyOwner,

View File

@@ -1608,6 +1608,18 @@ pub struct ExtensionPointInfoDto {
pub provider: Option<String>, pub provider: Option<String>,
} }
/// One row of the §9.4 interceptor report.
#[derive(Debug, Deserialize)]
pub struct InterceptorInfoDto {
pub service: String,
pub op: String,
#[serde(default)]
pub phase: String,
pub script: String,
#[serde(default)]
pub timeout_ms: Option<i32>,
}
impl Client { impl Client {
/// `GET /api/v1/admin/{apps|groups}/{ident}/extension-points`. /// `GET /api/v1/admin/{apps|groups}/{ident}/extension-points`.
pub async fn extension_points_list( pub async fn extension_points_list(
@@ -1626,6 +1638,24 @@ impl Client {
decode(resp).await decode(resp).await
} }
/// §9.4: `GET /api/v1/admin/{apps|groups}/{ident}/interceptors`. For an app,
/// the resolved chain view (what guards its writes); for a group, its own.
pub async fn interceptors_list(
&self,
kind: NodeKind,
ident: &str,
) -> Result<Vec<InterceptorInfoDto>> {
let ident = seg(ident);
let resp = self
.request(
Method::GET,
&format!("/api/v1/admin/{}/{ident}/interceptors", kind.path()),
)
.send()
.await?;
decode(resp).await
}
/// `GET /api/v1/admin/groups/{ident}/collections` (§11.6). Group-only — /// `GET /api/v1/admin/groups/{ident}/collections` (§11.6). Group-only —
/// shared collections are declared on groups. /// shared collections are declared on groups.
pub async fn collections_list(&self, group_ident: &str) -> Result<Vec<CollectionInfoDto>> { pub async fn collections_list(&self, group_ident: &str) -> Result<Vec<CollectionInfoDto>> {

View File

@@ -0,0 +1,36 @@
//! `pic interceptors ls --app|--group` — read-only view of a node's §9.4
//! service interceptors. For an app, the RESOLVED chain view (every marker
//! guarding its writes, nearest-owner-wins — what actually runs); for a group,
//! its own declared markers. Authoring is declarative only (the manifest
//! `[[interceptors]]`).
use anyhow::Result;
use crate::client::{Client, NodeKind};
use crate::cmds::OwnerRef;
use crate::config;
use crate::output::{OutputMode, Table};
pub async fn ls(app: Option<&str>, group: Option<&str>, mode: OutputMode) -> Result<()> {
let (kind, ident) = match crate::cmds::require_one_owner(app, group)? {
OwnerRef::App(a) => (NodeKind::App, a),
OwnerRef::Group(g) => (NodeKind::Group, g),
};
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let items = client.interceptors_list(kind, ident).await?;
let mut table = Table::new(["service", "op", "phase", "script", "timeout_ms"]);
for i in &items {
table.row([
i.service.clone(),
i.op.clone(),
i.phase.clone(),
i.script.clone(),
i.timeout_ms
.map_or_else(|| "-".to_string(), |t| t.to_string()),
]);
}
table.print(mode);
Ok(())
}

View File

@@ -13,6 +13,7 @@ pub mod extension_points;
pub mod files; pub mod files;
pub mod groups; pub mod groups;
pub mod init; pub mod init;
pub mod interceptors;
pub mod kv; pub mod kv;
pub mod login; pub mod login;
pub mod logout; pub mod logout;

View File

@@ -176,6 +176,15 @@ enum Cmd {
cmd: ExtensionPointsCmd, cmd: ExtensionPointsCmd,
}, },
/// Service interceptors (§9.4) — read-only view of the before/after hooks
/// guarding a node's data-plane writes. Authored declaratively via the
/// manifest `[[interceptors]]`; `--app` shows the resolved chain (what
/// runs), `--group` the group's own declarations.
Interceptors {
#[command(subcommand)]
cmd: InterceptorsCmd,
},
/// Shared group collections (§11.6) — read-only view of the KV collection /// Shared group collections (§11.6) — read-only view of the KV collection
/// names a group offers as cross-app-shared. Authored declaratively via the /// names a group offers as cross-app-shared. Authored declaratively via the
/// `[group]` manifest `collections = [...]`; scripts read/write them with /// `[group]` manifest `collections = [...]`; scripts read/write them with
@@ -661,6 +670,19 @@ enum ExtensionPointsCmd {
}, },
} }
#[derive(Subcommand)]
enum InterceptorsCmd {
/// List a node's service interceptors. `--app` shows the resolved chain
/// (every marker guarding its writes, nearest-owner-wins); `--group` shows
/// the group's own declarations.
Ls {
#[arg(long)]
app: Option<String>,
#[arg(long, conflicts_with = "app")]
group: Option<String>,
},
}
#[derive(Subcommand)] #[derive(Subcommand)]
enum CollectionsCmd { enum CollectionsCmd {
/// List the shared collections a group declares (§11.6). Group-only — /// List the shared collections a group declares (§11.6). Group-only —
@@ -2204,6 +2226,9 @@ async fn main() -> ExitCode {
Cmd::ExtensionPoints { Cmd::ExtensionPoints {
cmd: ExtensionPointsCmd::Ls { app, group }, cmd: ExtensionPointsCmd::Ls { app, group },
} => cmds::extension_points::ls(app.as_deref(), group.as_deref(), mode).await, } => cmds::extension_points::ls(app.as_deref(), group.as_deref(), mode).await,
Cmd::Interceptors {
cmd: InterceptorsCmd::Ls { app, group },
} => cmds::interceptors::ls(app.as_deref(), group.as_deref(), mode).await,
Cmd::Collections { Cmd::Collections {
cmd: CollectionsCmd::Ls { group }, cmd: CollectionsCmd::Ls { group },
} => cmds::collections::ls(&group, mode).await, } => cmds::collections::ls(&group, mode).await,

View File

@@ -872,3 +872,71 @@ fn a_runaway_interceptor_is_denied_by_its_timeout() {
"the timed-out write must NOT persist, got: {k}" "the timed-out write must NOT persist, got: {k}"
); );
} }
// --- §9.4 M12: set_if (CAS) is guarded + `pic interceptors ls` ------------
/// A writer that CAS-writes the guarded key (must be denied) and a free key
/// (must succeed) — proving `set_if` runs the `(kv, set)` guard, not a bypass.
const CAS_WRITER: &str = r#"
let denied = false;
try { kv::collection("c").set_if("secret", (), 1); } catch(e) { denied = true; }
let ok = kv::collection("c").set_if("free", (), 2);
#{ denied: denied, ok: ok }
"#;
/// M12: `set_if` (compare-and-swap) is a write, so it runs the same `(kv, set)`
/// interceptor as `set` — otherwise CAS would be a silent bypass of a set policy.
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn set_if_runs_the_set_interceptor() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let app = common::unique_slug("cas-app");
let _a = AppGuard::new(&env.url, &env.token, &app);
common::pic_as(&env)
.args(["apps", "create", &app])
.assert()
.success();
let dir = manifest_dir();
fs::write(dir.path().join("scripts/guard.rhai"), GUARD).unwrap();
fs::write(dir.path().join("scripts/writer.rhai"), CAS_WRITER).unwrap();
fs::write(
dir.path().join("picloud.toml"),
format!(
"[app]\nslug = \"{app}\"\nname = \"CAS\"\n\n\
[[scripts]]\nname = \"guard\"\nfile = \"scripts/guard.rhai\"\n\n\
[[scripts]]\nname = \"writer\"\nfile = \"scripts/writer.rhai\"\n\n\
[[interceptors]]\nscript = \"guard\"\nops = [\"set\"]\n"
),
)
.unwrap();
common::pic_as(&env)
.args(["apply", "--file"])
.arg(dir.path().join("picloud.toml"))
.assert()
.success();
let body = invoke_body(&env, &app_script_id(&env, &app, "writer"));
assert_eq!(
body,
serde_json::json!({ "denied": true, "ok": true }),
"set_if of the guarded key must be denied; a free key must swap"
);
// `pic interceptors ls --app` shows the resolved marker.
let ls = String::from_utf8(
common::pic_as(&env)
.args(["interceptors", "ls", "--app", &app])
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(
ls.contains("kv") && ls.contains("set") && ls.contains("guard"),
"interceptors ls --app must list the kv/set guard:\n{ls}"
);
}