feat(interceptors): KV after-hooks + before-hook data transform (§9.4 M3+M4)
M4: a before-interceptor returning #{ allowed: true, data: ... } rewrites the
value actually written (threaded through the chain; each hook sees the prior
transform), size-capped at MAX_JSON_MATERIALIZE_BYTES. Delete never transforms.
M3: an 'after' phase interceptor runs once the write has committed, with the
write's result in its payload. After-hooks observe/deny but CANNOT roll back —
a deny surfaces as an operation error while the write persists (documented at
the call site).
Adds phase authoring end to end: a [[interceptors]] entry gains phase =
before|after (default before), threaded through the plan wire, BundleInterceptor,
validate (phase in {before,after}), the reconcile diff/insert/prune key
(service/op/phase), and the repo (insert/delete/list_for_owner/list_on_app_chain,
+ the marker's phase). run_before now returns the transformed value; run_after is
new; both share one fail-closed per-entry runner. Pinned by two journeys: a
before-hook transforms the stored value, and an after-delete hook sees
result==true, denies, yet the key stays deleted.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -263,7 +263,7 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
|
||||
.iter()
|
||||
.flat_map(|i| {
|
||||
i.ops.iter().map(move |op| {
|
||||
json!({ "service": i.service, "op": op, "script": i.script })
|
||||
json!({ "service": i.service, "op": op, "phase": i.phase, "script": i.script })
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
|
||||
@@ -83,12 +83,21 @@ pub struct ManifestInterceptor {
|
||||
pub service: String,
|
||||
/// The guarded operations, e.g. `["set", "delete"]`.
|
||||
pub ops: Vec<String>,
|
||||
/// When the hook runs relative to the op: `"before"` (default —
|
||||
/// allow/deny + data transform) or `"after"` (§9.4 M3 — observe/audit;
|
||||
/// cannot roll back the write).
|
||||
#[serde(default = "default_interceptor_phase")]
|
||||
pub phase: String,
|
||||
}
|
||||
|
||||
fn default_interceptor_service() -> String {
|
||||
"kv".to_string()
|
||||
}
|
||||
|
||||
fn default_interceptor_phase() -> String {
|
||||
"before".to_string()
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
/// Parse a manifest from TOML text. Enforces the app-XOR-group invariant.
|
||||
pub fn parse(text: &str) -> Result<Self> {
|
||||
|
||||
@@ -594,3 +594,136 @@ fn a_self_referential_interceptor_does_not_recurse() {
|
||||
.unwrap();
|
||||
assert!(x.contains('1'), "the original write must persist:\n{x}");
|
||||
}
|
||||
|
||||
// --- §9.4 M4: data transform (before) -------------------------------------
|
||||
|
||||
/// A before-interceptor that REWRITES the written value via `data` for the key
|
||||
/// `"doc"` (allow + transform), passing everything else through unchanged.
|
||||
const TRANSFORM_GUARD: &str = r#"
|
||||
let op = ctx.request.body;
|
||||
if op.action == "set" && op.key == "doc" {
|
||||
#{ allowed: true, data: #{ redacted: true } }
|
||||
} else {
|
||||
#{ allowed: true }
|
||||
}
|
||||
"#;
|
||||
|
||||
/// A writer that sets `doc` to a raw value and reads back what was stored.
|
||||
const TRANSFORM_WRITER: &str = r#"
|
||||
kv::collection("c").set("doc", #{ secret: "raw" });
|
||||
kv::collection("c").get("doc")
|
||||
"#;
|
||||
|
||||
/// M4: a before-interceptor that returns `#{ allowed: true, data: ... }` rewrites
|
||||
/// the value actually written — the store holds the transform, not the original.
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn a_before_interceptor_transforms_the_written_value() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let app = common::unique_slug("xf-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"), TRANSFORM_GUARD).unwrap();
|
||||
fs::write(dir.path().join("scripts/writer.rhai"), TRANSFORM_WRITER).unwrap();
|
||||
fs::write(
|
||||
dir.path().join("picloud.toml"),
|
||||
format!(
|
||||
"[app]\nslug = \"{app}\"\nname = \"XF\"\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();
|
||||
|
||||
// The writer reads back the STORED value — it must be the transform.
|
||||
let body = invoke_body(&env, &app_script_id(&env, &app, "writer"));
|
||||
assert_eq!(
|
||||
body,
|
||||
serde_json::json!({ "redacted": true }),
|
||||
"the stored value must be the interceptor's `data` transform, not the original"
|
||||
);
|
||||
}
|
||||
|
||||
// --- §9.4 M3: after-hooks --------------------------------------------------
|
||||
|
||||
/// An AFTER-interceptor on `delete` that reads the write's outcome
|
||||
/// (`op.result`, the was-present bool) and DENIES when a key was actually
|
||||
/// removed. It cannot roll the delete back (the write already committed) — the
|
||||
/// deny only surfaces as an error to the caller.
|
||||
const AFTER_DELETE_GUARD: &str = r#"
|
||||
let op = ctx.request.body;
|
||||
if op.action == "delete" && op.result == true {
|
||||
#{ allowed: false, reason: "an existing key was deleted" }
|
||||
} else {
|
||||
#{ allowed: true }
|
||||
}
|
||||
"#;
|
||||
|
||||
/// A writer that seeds a key, deletes it (triggering the after-hook), then
|
||||
/// checks whether the key survived.
|
||||
const AFTER_DELETE_WRITER: &str = r#"
|
||||
kv::collection("c").set("k", 1);
|
||||
let after_denied = false;
|
||||
try { kv::collection("c").delete("k"); } catch(e) { after_denied = true; }
|
||||
let still = kv::collection("c").has("k");
|
||||
#{ after_denied: after_denied, still: still }
|
||||
"#;
|
||||
|
||||
/// M3: an after-hook runs with the write's `result` in its payload, can DENY
|
||||
/// (surfacing an error to the caller), but CANNOT roll the write back. The
|
||||
/// delete's `result` (was-present = true) drives the deny, and the key is gone
|
||||
/// afterwards despite the error.
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn an_after_hook_sees_the_result_and_cannot_roll_back() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let app = common::unique_slug("after-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"), AFTER_DELETE_GUARD).unwrap();
|
||||
fs::write(dir.path().join("scripts/writer.rhai"), AFTER_DELETE_WRITER).unwrap();
|
||||
fs::write(
|
||||
dir.path().join("picloud.toml"),
|
||||
format!(
|
||||
"[app]\nslug = \"{app}\"\nname = \"After\"\n\n\
|
||||
[[scripts]]\nname = \"guard\"\nfile = \"scripts/guard.rhai\"\n\n\
|
||||
[[scripts]]\nname = \"writer\"\nfile = \"scripts/writer.rhai\"\n\n\
|
||||
[[interceptors]]\nscript = \"guard\"\nops = [\"delete\"]\nphase = \"after\"\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!({ "after_denied": true, "still": false }),
|
||||
"the after-hook must see result==true and deny, but the delete must have persisted"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user