feat(interceptors): §9.4 service interceptors — thin KV allow/deny slice
Smallest honest vertical slice of §9.4: a `[[interceptors]]` block (app OR
group) binds a script to run BEFORE `kv::set`/`delete`; it reads the operation
context (`ctx.request.body`: service, action, collection, key, value, caller
ids) and returns `#{ allowed, reason }` — `allowed == false` denies the op (the
write never runs, the caller gets a runtime error).
Reuses two existing mechanisms rather than inventing new ones:
- Registration mirrors extension points (§5.5): a marker table
`0073_interceptors.sql` (owner-polymorphic app_id/group_id XOR, keyed
(service, op) → script), `interceptor_repo` (insert/delete/list + the
nearest-owner-wins `resolve_before` chain walk), reconciled through the
declarative apply exactly like `vars` (create/update/delete, prunable).
- Execution reuses the `invoke()` re-entry path: the new `InterceptorService`
(shared trait + Postgres-backed impl) only RESOLVES the script name (keeping
executor-core Postgres-free); the executor's `sdk::interceptor::run_before`
resolves that name and runs it via `run_resolved_blocking` (extracted from
`invoke_blocking` — shared depth bound + AST cache). An un-hooked write pays
one indexed `Ok(None)` resolve; no interceptor ⇒ zero overhead.
Nearest-owner-wins so an app overrides a group's interceptor, and a group
interceptor is inherited by every descendant app — the chain walk is the
isolation boundary (a sibling subtree never matches). `validate_bundle_for`
restricts the MVP to `service = "kv"`, `op ∈ {set, delete}`, one marker per
(service, op).
Deferred (documented in §9.4): the `data` transform return, services other
than kv, `after_*` hooks, chaining + circular-dependency guard, the timeout
policy, and a `pic interceptors ls` read surface (needs a server route).
Pinned by `tests/interceptors.rs` (deny blocks the write; allow passes;
group→app inheritance), schema snapshot re-blessed. 154/154 journeys pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -163,9 +163,28 @@ fn invoke_blocking(
|
||||
.into()
|
||||
})?;
|
||||
|
||||
let execution_id = ExecutionId::new();
|
||||
// The callee's return is the response `body` JSON. Convert back to
|
||||
// Dynamic for the caller. Status code + headers are dropped; the
|
||||
// function-call mental model is "return value", not HTTP response.
|
||||
let body = run_resolved_blocking(self_engine, cx, &resolved, args_json, &target_label)?;
|
||||
Ok(json_to_dynamic(body))
|
||||
}
|
||||
|
||||
/// Synchronous same-engine re-entry: build the callee `ExecRequest` (inheriting
|
||||
/// the caller's app/principal/root/depth+1 and the callee's lexical owner),
|
||||
/// compile through the per-Engine AST cache (F-P-004), execute, and return the
|
||||
/// response `body` JSON. Shared by `invoke()` and the §9.4 interceptor hook —
|
||||
/// the caller is responsible for the depth check (both do it before resolving).
|
||||
/// `label` prefixes any compile/execute error.
|
||||
pub(super) fn run_resolved_blocking(
|
||||
self_engine: &Arc<Engine>,
|
||||
cx: &Arc<SdkCallCx>,
|
||||
resolved: &picloud_shared::ResolvedScript,
|
||||
body_json: Json,
|
||||
label: &str,
|
||||
) -> Result<Json, Box<EvalAltResult>> {
|
||||
let req = ExecRequest {
|
||||
execution_id,
|
||||
execution_id: ExecutionId::new(),
|
||||
request_id: cx.request_id,
|
||||
script_id: resolved.script_id,
|
||||
script_name: resolved.name.clone(),
|
||||
@@ -173,7 +192,7 @@ fn invoke_blocking(
|
||||
path: "/invoke".into(),
|
||||
method: String::new(),
|
||||
headers: BTreeMap::new(),
|
||||
body: args_json,
|
||||
body: body_json,
|
||||
params: BTreeMap::new(),
|
||||
query: BTreeMap::new(),
|
||||
rest: String::new(),
|
||||
@@ -183,42 +202,24 @@ fn invoke_blocking(
|
||||
// script's imports resolve from the group even when invoked by an
|
||||
// app. `None` falls back to `App(cx.app_id)` in the engine.
|
||||
script_owner: resolved.owner,
|
||||
// Same-app invoke is a function call, not a re-auth boundary —
|
||||
// inherit the caller's principal.
|
||||
// Same-app re-entry is not a re-auth boundary — inherit the principal.
|
||||
principal: cx.principal.clone(),
|
||||
trigger_depth: cx.trigger_depth + 1,
|
||||
root_execution_id: cx.root_execution_id,
|
||||
is_dead_letter_handler: cx.is_dead_letter_handler,
|
||||
event: None,
|
||||
};
|
||||
|
||||
// F-P-004: synchronous re-entry — route through the per-Engine
|
||||
// AST cache so each callee parses once per (script_id, updated_at),
|
||||
// not once per invoke. Composed workflows multiply parse cost by
|
||||
// depth; the cache cuts that to constant compile + N executions.
|
||||
let ast = self_engine
|
||||
.compile_for_identity(resolved.script_id, resolved.updated_at, &resolved.source)
|
||||
.map_err(|e| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(
|
||||
format!("invoke({target_label}): {e}").into(),
|
||||
rhai::Position::NONE,
|
||||
)
|
||||
.into()
|
||||
EvalAltResult::ErrorRuntime(format!("{label}: {e}").into(), rhai::Position::NONE).into()
|
||||
})?;
|
||||
let resp = self_engine
|
||||
.execute_ast(&ast, req)
|
||||
.map_err(|e| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(
|
||||
format!("invoke({target_label}): {e}").into(),
|
||||
rhai::Position::NONE,
|
||||
)
|
||||
.into()
|
||||
EvalAltResult::ErrorRuntime(format!("{label}: {e}").into(), rhai::Position::NONE).into()
|
||||
})?;
|
||||
|
||||
// The callee's return is the response `body` JSON. Convert back to
|
||||
// Dynamic for the caller. Status code + headers are dropped; the
|
||||
// function-call mental model is "return value", not HTTP response.
|
||||
Ok(json_to_dynamic(resp.body))
|
||||
Ok(resp.body)
|
||||
}
|
||||
|
||||
/// Accept a string (route path OR script name) or a Rhai script-id
|
||||
|
||||
Reference in New Issue
Block a user