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:
@@ -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