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:
MechaCat02
2026-07-16 19:55:44 +02:00
parent be0c618672
commit 5592f1c97e
7 changed files with 496 additions and 162 deletions

View File

@@ -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<_>>(),

View File

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

View File

@@ -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"
);
}