feat(interceptors): extend before/after hooks to docs/files/queue/pubsub/http (§9.4 M7-M11)

Every non-KV mutating op now runs the same before/after interceptor machinery:
- docs create/update/delete (+ group) — create/update honor the M4 data transform;
- files create/update/delete (+ group) — allow/deny only (the blob is never
  surfaced to the hook, value = None);
- queue enqueue (+ shared) — transform-capable; after reports the message id;
- pubsub publish_durable (+ shared topic) — transform-capable;
- http request — all verbs funnel through two svc.request sites; collection =
  method, key = url, body not surfaced.
ictx threaded into the free-fn/handle paths that lacked it (http was _ictx;
queue/pubsub per-app publish).

validate_bundle_for generalized to a per-service allowed-ops map (kv set/delete;
docs/files create/update/delete; queue enqueue; pubsub publish; http request) —
unknown service/op still rejected. INVARIANT enforced + verified: every allowed
(service, op) has a matching runtime hook, so no validated-but-unhooked
fail-open. Pinned by a new docs-create deny journey (11 interceptor journeys green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-16 20:37:18 +02:00
parent faf04174c4
commit ef16d48a8c
8 changed files with 564 additions and 112 deletions

View File

@@ -51,29 +51,31 @@ const MAX_REDIRECTS: i64 = 10;
const ALLOWED_OPT_KEYS: [&str; 4] = ["headers", "timeout_ms", "follow_redirects", "max_redirects"];
// `http` registers its verbs as module native fns (no handle struct), so there
// is nowhere to stash the interceptor ctx yet. The param is threaded uniformly
// from `register_all` (§9.4 M1) and consumed by M11, which adds the before/after
// hook around the outbound-request closures. Prefixed `_` until then.
// `http` registers its verbs as module native fns (no handle struct), so the
// interceptor ctx is captured into each request closure directly. Every verb
// funnels through `invoke` / `invoke_form`, which run the §9.4 before/after hook
// around `svc.request` (M11). The hook sees `service = "http"`, `op =
// "request"`, `collection = <METHOD>`, `key = <url>`; the body is never
// surfaced, so there is no data transform.
pub(super) fn register(
engine: &mut RhaiEngine,
services: &Services,
cx: Arc<SdkCallCx>,
_ictx: InterceptorCtx,
ictx: InterceptorCtx,
) {
let svc = services.http.clone();
let mut module = Module::new();
// Bodyless verbs: (url) / (url, opts).
for verb in ["get", "head"] {
register_bodyless(&mut module, verb, &svc, &cx);
register_bodyless(&mut module, verb, &svc, &cx, &ictx);
}
// Body verbs: (url) / (url, body) / (url, body, opts).
for verb in ["post", "put", "patch", "delete"] {
register_body(&mut module, verb, &svc, &cx);
register_body(&mut module, verb, &svc, &cx, &ictx);
}
register_post_form(&mut module, &svc, &cx);
register_request(&mut module, &svc, &cx);
register_post_form(&mut module, &svc, &cx, &ictx);
register_request(&mut module, &svc, &cx, &ictx);
engine.register_static_module("http", module.into());
}
@@ -83,17 +85,18 @@ fn register_bodyless(
verb: &'static str,
svc: &Arc<dyn HttpService>,
cx: &Arc<SdkCallCx>,
ictx: &InterceptorCtx,
) {
{
let (svc, cx) = (svc.clone(), cx.clone());
let (svc, cx, ictx) = (svc.clone(), cx.clone(), ictx.clone());
module.set_native_fn(verb, move |url: &str| {
invoke(&svc, &cx, verb, url, None, None)
invoke(&ictx, &svc, &cx, verb, url, None, None)
});
}
{
let (svc, cx) = (svc.clone(), cx.clone());
let (svc, cx, ictx) = (svc.clone(), cx.clone(), ictx.clone());
module.set_native_fn(verb, move |url: &str, opts: Map| {
invoke(&svc, &cx, verb, url, None, Some(&opts))
invoke(&ictx, &svc, &cx, verb, url, None, Some(&opts))
});
}
}
@@ -103,61 +106,72 @@ fn register_body(
verb: &'static str,
svc: &Arc<dyn HttpService>,
cx: &Arc<SdkCallCx>,
ictx: &InterceptorCtx,
) {
{
let (svc, cx) = (svc.clone(), cx.clone());
let (svc, cx, ictx) = (svc.clone(), cx.clone(), ictx.clone());
module.set_native_fn(verb, move |url: &str| {
invoke(&svc, &cx, verb, url, None, None)
invoke(&ictx, &svc, &cx, verb, url, None, None)
});
}
{
let (svc, cx) = (svc.clone(), cx.clone());
let (svc, cx, ictx) = (svc.clone(), cx.clone(), ictx.clone());
module.set_native_fn(verb, move |url: &str, body: Dynamic| {
invoke(&svc, &cx, verb, url, Some(body), None)
invoke(&ictx, &svc, &cx, verb, url, Some(body), None)
});
}
{
let (svc, cx) = (svc.clone(), cx.clone());
let (svc, cx, ictx) = (svc.clone(), cx.clone(), ictx.clone());
module.set_native_fn(verb, move |url: &str, body: Dynamic, opts: Map| {
invoke(&svc, &cx, verb, url, Some(body), Some(&opts))
invoke(&ictx, &svc, &cx, verb, url, Some(body), Some(&opts))
});
}
}
fn register_post_form(module: &mut Module, svc: &Arc<dyn HttpService>, cx: &Arc<SdkCallCx>) {
fn register_post_form(
module: &mut Module,
svc: &Arc<dyn HttpService>,
cx: &Arc<SdkCallCx>,
ictx: &InterceptorCtx,
) {
{
let (svc, cx) = (svc.clone(), cx.clone());
let (svc, cx, ictx) = (svc.clone(), cx.clone(), ictx.clone());
module.set_native_fn("post_form", move |url: &str, form: Map| {
invoke_form(&svc, &cx, url, &form, None)
invoke_form(&ictx, &svc, &cx, url, &form, None)
});
}
{
let (svc, cx) = (svc.clone(), cx.clone());
let (svc, cx, ictx) = (svc.clone(), cx.clone(), ictx.clone());
module.set_native_fn("post_form", move |url: &str, form: Map, opts: Map| {
invoke_form(&svc, &cx, url, &form, Some(&opts))
invoke_form(&ictx, &svc, &cx, url, &form, Some(&opts))
});
}
}
fn register_request(module: &mut Module, svc: &Arc<dyn HttpService>, cx: &Arc<SdkCallCx>) {
fn register_request(
module: &mut Module,
svc: &Arc<dyn HttpService>,
cx: &Arc<SdkCallCx>,
ictx: &InterceptorCtx,
) {
{
let (svc, cx) = (svc.clone(), cx.clone());
let (svc, cx, ictx) = (svc.clone(), cx.clone(), ictx.clone());
module.set_native_fn("request", move |method: &str, url: &str| {
invoke(&svc, &cx, method, url, None, None)
invoke(&ictx, &svc, &cx, method, url, None, None)
});
}
{
let (svc, cx) = (svc.clone(), cx.clone());
let (svc, cx, ictx) = (svc.clone(), cx.clone(), ictx.clone());
module.set_native_fn("request", move |method: &str, url: &str, body: Dynamic| {
invoke(&svc, &cx, method, url, Some(body), None)
invoke(&ictx, &svc, &cx, method, url, Some(body), None)
});
}
{
let (svc, cx) = (svc.clone(), cx.clone());
let (svc, cx, ictx) = (svc.clone(), cx.clone(), ictx.clone());
module.set_native_fn(
"request",
move |method: &str, url: &str, body: Dynamic, opts: Map| {
invoke(&svc, &cx, method, url, Some(body), Some(&opts))
invoke(&ictx, &svc, &cx, method, url, Some(body), Some(&opts))
},
);
}
@@ -260,8 +274,9 @@ fn dispatch_body(body: Dynamic) -> Result<EncodedBody, Box<EvalAltResult>> {
Ok((Some(bytes), Some("application/json".to_string())))
}
#[allow(clippy::needless_pass_by_value)]
#[allow(clippy::needless_pass_by_value, clippy::too_many_arguments)]
fn invoke(
ictx: &InterceptorCtx,
svc: &Arc<dyn HttpService>,
cx: &Arc<SdkCallCx>,
method: &str,
@@ -271,6 +286,9 @@ fn invoke(
) -> Result<Dynamic, Box<EvalAltResult>> {
let opts = parse_opts(opts)?;
let method_uc = method.to_ascii_uppercase();
// §9.4 before-op interceptor (allow/deny only; the body is not surfaced to
// the hook). `collection` = the HTTP method, `key` = the URL.
super::interceptor::run_before(ictx, cx, "http", "request", &method_uc, url, None)?;
let bodyless = matches!(method_uc.as_str(), "GET" | "HEAD");
let (encoded, content_type) = if bodyless {
(None, None)
@@ -281,7 +299,7 @@ fn invoke(
};
let req = HttpRequest {
method: method_uc,
method: method_uc.clone(),
url: url.to_string(),
headers: opts.headers,
body: encoded,
@@ -296,11 +314,22 @@ fn invoke(
let cx = cx.clone();
block_on("http", async move { svc.request(&cx, req).await })?
};
super::interceptor::run_after(
ictx,
cx,
"http",
"request",
&method_uc,
url,
None,
serde_json::Value::Null,
)?;
Ok(response_to_dynamic(&resp))
}
#[allow(clippy::needless_pass_by_value)]
fn invoke_form(
ictx: &InterceptorCtx,
svc: &Arc<dyn HttpService>,
cx: &Arc<SdkCallCx>,
url: &str,
@@ -308,6 +337,8 @@ fn invoke_form(
opts: Option<&Map>,
) -> Result<Dynamic, Box<EvalAltResult>> {
let opts = parse_opts(opts)?;
// §9.4 before-op interceptor. `post_form` is always a POST.
super::interceptor::run_before(ictx, cx, "http", "request", "POST", url, None)?;
let mut serializer = url::form_urlencoded::Serializer::new(String::new());
for (k, v) in form {
serializer.append_pair(k.as_str(), &dyn_to_string(v));
@@ -330,6 +361,16 @@ fn invoke_form(
let cx = cx.clone();
block_on("http", async move { svc.request(&cx, req).await })?
};
super::interceptor::run_after(
ictx,
cx,
"http",
"request",
"POST",
url,
None,
serde_json::Value::Null,
)?;
Ok(response_to_dynamic(&resp))
}