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:
@@ -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>> {
|
||||
|
||||
36
crates/picloud-cli/src/cmds/interceptors.rs
Normal file
36
crates/picloud-cli/src/cmds/interceptors.rs
Normal 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(())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user