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

View File

@@ -1608,6 +1608,18 @@ pub struct ExtensionPointInfoDto {
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 {
/// `GET /api/v1/admin/{apps|groups}/{ident}/extension-points`.
pub async fn extension_points_list(
@@ -1626,6 +1638,24 @@ impl Client {
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 —
/// shared collections are declared on groups.
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 groups;
pub mod init;
pub mod interceptors;
pub mod kv;
pub mod login;
pub mod logout;

View File

@@ -176,6 +176,15 @@ enum Cmd {
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
/// names a group offers as cross-app-shared. Authored declaratively via the
/// `[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)]
enum CollectionsCmd {
/// List the shared collections a group declares (§11.6). Group-only —
@@ -2204,6 +2226,9 @@ async fn main() -> ExitCode {
Cmd::ExtensionPoints {
cmd: ExtensionPointsCmd::Ls { app, group },
} => 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: CollectionsCmd::Ls { group },
} => 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}"
);
}
// --- §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}"
);
}