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:
@@ -172,9 +172,9 @@ fn register_set(engine: &mut RhaiEngine) {
|
||||
"set",
|
||||
|handle: &mut KvHandle, key: &str, value: Dynamic| -> Result<(), Box<EvalAltResult>> {
|
||||
let json = dynamic_to_json_capped(&value, MAX_JSON_MATERIALIZE_BYTES)?;
|
||||
// §9.4 before-op interceptor (allow/deny). A denial errors here and
|
||||
// the write below never runs.
|
||||
super::interceptor::run_before(
|
||||
// §9.4 before-op interceptor (allow/deny + M4 data-transform). A
|
||||
// denial errors here; a returned `data` rewrites the written value.
|
||||
let write_val = super::interceptor::run_before(
|
||||
&handle.ictx,
|
||||
&handle.cx,
|
||||
"kv",
|
||||
@@ -182,11 +182,24 @@ fn register_set(engine: &mut RhaiEngine) {
|
||||
&handle.collection,
|
||||
key,
|
||||
Some(&json),
|
||||
)?;
|
||||
)?
|
||||
.unwrap_or(json);
|
||||
let written = write_val.clone();
|
||||
let h = handle.clone();
|
||||
block_on("kv", async move {
|
||||
h.service.set(&h.cx, &h.collection, key, json).await
|
||||
})
|
||||
h.service.set(&h.cx, &h.collection, key, write_val).await
|
||||
})?;
|
||||
// §9.4 M3 after-hook (observe/audit; the write already committed).
|
||||
super::interceptor::run_after(
|
||||
&handle.ictx,
|
||||
&handle.cx,
|
||||
"kv",
|
||||
"set",
|
||||
&handle.collection,
|
||||
key,
|
||||
Some(&written),
|
||||
serde_json::Value::Null,
|
||||
)
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -228,6 +241,7 @@ fn register_delete(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"delete",
|
||||
|handle: &mut KvHandle, key: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||
// Delete carries no value, so the before-hook never transforms.
|
||||
super::interceptor::run_before(
|
||||
&handle.ictx,
|
||||
&handle.cx,
|
||||
@@ -238,9 +252,20 @@ fn register_delete(engine: &mut RhaiEngine) {
|
||||
None,
|
||||
)?;
|
||||
let h = handle.clone();
|
||||
block_on("kv", async move {
|
||||
let was_present = block_on("kv", async move {
|
||||
h.service.delete(&h.cx, &h.collection, key).await
|
||||
})
|
||||
})?;
|
||||
super::interceptor::run_after(
|
||||
&handle.ictx,
|
||||
&handle.cx,
|
||||
"kv",
|
||||
"delete",
|
||||
&handle.collection,
|
||||
key,
|
||||
None,
|
||||
serde_json::Value::Bool(was_present),
|
||||
)?;
|
||||
Ok(was_present)
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -252,7 +277,7 @@ fn group_run_before(
|
||||
op: &'static str,
|
||||
key: &str,
|
||||
value: Option<&serde_json::Value>,
|
||||
) -> Result<(), Box<EvalAltResult>> {
|
||||
) -> Result<Option<serde_json::Value>, Box<EvalAltResult>> {
|
||||
super::interceptor::run_before(
|
||||
&handle.ictx,
|
||||
&handle.cx,
|
||||
@@ -264,6 +289,25 @@ fn group_run_before(
|
||||
)
|
||||
}
|
||||
|
||||
fn group_run_after(
|
||||
handle: &GroupKvHandle,
|
||||
op: &'static str,
|
||||
key: &str,
|
||||
value: Option<&serde_json::Value>,
|
||||
result: serde_json::Value,
|
||||
) -> Result<(), Box<EvalAltResult>> {
|
||||
super::interceptor::run_after(
|
||||
&handle.ictx,
|
||||
&handle.cx,
|
||||
"kv",
|
||||
op,
|
||||
&handle.collection,
|
||||
key,
|
||||
value,
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
fn register_list(engine: &mut RhaiEngine) {
|
||||
// Zero-arg form — full page, no cursor.
|
||||
engine.register_fn(
|
||||
@@ -330,12 +374,15 @@ fn register_group_set(engine: &mut RhaiEngine) {
|
||||
"set",
|
||||
|handle: &mut GroupKvHandle, key: &str, value: Dynamic| -> Result<(), Box<EvalAltResult>> {
|
||||
let json = dynamic_to_json_capped(&value, MAX_JSON_MATERIALIZE_BYTES)?;
|
||||
// §9.4 before-op interceptor also guards shared-collection writes.
|
||||
group_run_before(handle, "set", key, Some(&json))?;
|
||||
// §9.4 before-op interceptor also guards shared-collection writes
|
||||
// (allow/deny + M4 data-transform).
|
||||
let write_val = group_run_before(handle, "set", key, Some(&json))?.unwrap_or(json);
|
||||
let written = write_val.clone();
|
||||
let h = handle.clone();
|
||||
block_on("kv", async move {
|
||||
h.service.set(&h.cx, &h.collection, key, json).await
|
||||
})
|
||||
h.service.set(&h.cx, &h.collection, key, write_val).await
|
||||
})?;
|
||||
group_run_after(handle, "set", key, Some(&written), serde_json::Value::Null)
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -376,9 +423,17 @@ fn register_group_delete(engine: &mut RhaiEngine) {
|
||||
|handle: &mut GroupKvHandle, key: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||
group_run_before(handle, "delete", key, None)?;
|
||||
let h = handle.clone();
|
||||
block_on("kv", async move {
|
||||
let was_present = block_on("kv", async move {
|
||||
h.service.delete(&h.cx, &h.collection, key).await
|
||||
})
|
||||
})?;
|
||||
group_run_after(
|
||||
handle,
|
||||
"delete",
|
||||
key,
|
||||
None,
|
||||
serde_json::Value::Bool(was_present),
|
||||
)?;
|
||||
Ok(was_present)
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user