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

@@ -78,7 +78,8 @@ pub struct Manifest {
pub struct ManifestInterceptor {
/// Name of the interceptor script (resolved on the node's chain at run time).
pub script: String,
/// The guarded service. MVP: only `"kv"`.
/// The guarded service: one of `kv` / `docs` / `files` / `queue` /
/// `pubsub` / `http` (§9.4 M7M11). Defaults to `kv`.
#[serde(default = "default_interceptor_service")]
pub service: String,
/// The guarded operations, e.g. `["set", "delete"]`.

View File

@@ -398,6 +398,78 @@ fn a_malformed_verdict_fails_closed() {
);
}
// --- §9.4 M8: docs (a representative non-KV service) -----------------------
/// A `(docs, create)` guard that denies creating a doc whose `data.kind` is
/// `"secret"`, reading the written value from `ctx.request.body.value`. Proves a
/// non-KV service funnels through the same before/after machinery and that the
/// created document's data is surfaced to the hook.
const DOCS_GUARD: &str = r#"
let op = ctx.request.body;
if op.action == "create" && op.value.kind == "secret" {
#{ allowed: false, reason: "secret docs are protected" }
} else {
#{ allowed: true }
}
"#;
/// A writer that tries a denied `docs::create` (caught), then a permitted one,
/// and reports how many docs actually landed in the collection.
const DOCS_WRITER: &str = r#"
let denied = false;
try { docs::collection("c").create(#{ kind: "secret", n: 1 }); } catch(e) { denied = true; }
docs::collection("c").create(#{ kind: "public", n: 2 });
let all = docs::collection("c").find(#{});
#{ denied: denied, count: all.len() }
"#;
/// §9.4 M8: `[[interceptors]] service = "docs" ops = ["create"]` guards
/// `docs::collection(...).create(...)` through the same before/after machinery
/// as KV — a guarded shape is denied (the doc is never created), an unguarded
/// one is allowed.
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn an_interceptor_guards_docs_create() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let app = common::unique_slug("icd-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"), DOCS_GUARD).unwrap();
fs::write(dir.path().join("scripts/writer.rhai"), DOCS_WRITER).unwrap();
fs::write(
dir.path().join("picloud.toml"),
format!(
"[app]\nslug = \"{app}\"\nname = \"ICD\"\n\n\
[[scripts]]\nname = \"guard\"\nfile = \"scripts/guard.rhai\"\n\n\
[[scripts]]\nname = \"writer\"\nfile = \"scripts/writer.rhai\"\n\n\
[[interceptors]]\nscript = \"guard\"\nservice = \"docs\"\nops = [\"create\"]\n"
),
)
.unwrap();
common::pic_as(&env)
.args(["apply", "--file"])
.arg(dir.path().join("picloud.toml"))
.assert()
.success();
// The guarded (secret) create is denied; the public one lands — so exactly
// one doc exists in the collection.
let body = invoke_body(&env, &app_script_id(&env, &app, "writer"));
assert_eq!(
body,
serde_json::json!({ "denied": true, "count": 1 }),
"the `secret` docs::create must be denied; only the public doc is created"
);
}
// --- §9.4 M2: ordered before-chain + cycle guard --------------------------
/// A group before-interceptor that denies only `kv::set` of key `"group-key"`.