feat(interceptors): ordered before-chains + cycle guard + ctx plumbing (§9.4 M1+M2)
M1: introduces a clone-cheap InterceptorCtx { interceptors, self_engine,
limits } threaded into every SDK register fn (kv/docs/files/queue/pubsub/http)
so the non-KV services carry the hook seam (unused until M7-M11); KvHandle
collapses its three fields into one ictx.
M2: replaces the nearest-only resolver with ordered before/after chains. The
trait becomes resolve(cx, service, op) -> InterceptorChain { before, after }
(migration 0074 adds a phase column + phase-aware unique indexes). The before
-chain runs ancestor->app (depth DESC) so a group compliance guard can't be
bypassed by a descendant; single-marker behavior is byte-identical to before.
An identity cycle guard (thread-local visited-set keyed by script_id) denies a
detected cycle, alongside the existing binary re-entrancy break. Fail-closed
verdict preserved (allow only on #{ allowed: true }; a Dangling entry or a
missing engine back-ref denies); app_id still derives from cx.app_id only.
after-chains resolve but stay unused until M3. Pinned by two new interceptor
journeys (ancestor->app chain ordering; self-referential no-recurse).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,7 @@ use uuid::Uuid;
|
||||
use super::bridge::{
|
||||
block_on, dynamic_to_json_capped, json_to_dynamic, MAX_JSON_MATERIALIZE_BYTES,
|
||||
};
|
||||
use super::interceptor::InterceptorCtx;
|
||||
|
||||
/// Per-call handle captured by the Rhai SDK. Cheap to clone (two Arcs
|
||||
/// plus an owned string).
|
||||
@@ -38,6 +39,10 @@ pub struct DocsHandle {
|
||||
collection: String,
|
||||
service: Arc<dyn DocsService>,
|
||||
cx: Arc<SdkCallCx>,
|
||||
// §9.4 M1 plumbing: carried so `create`/`update`/`delete` can run a before/
|
||||
// after hook in a later milestone (M8). Unused until then.
|
||||
#[allow(dead_code)]
|
||||
ictx: InterceptorCtx,
|
||||
}
|
||||
|
||||
/// §11.6 shared-collection handle, returned by `docs::shared_collection(name)`.
|
||||
@@ -49,9 +54,17 @@ pub struct GroupDocsHandle {
|
||||
collection: String,
|
||||
service: Arc<dyn GroupDocsService>,
|
||||
cx: Arc<SdkCallCx>,
|
||||
// §9.4 M1 plumbing (see `DocsHandle`). Unused until M8.
|
||||
#[allow(dead_code)]
|
||||
ictx: InterceptorCtx,
|
||||
}
|
||||
|
||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||
pub(super) fn register(
|
||||
engine: &mut RhaiEngine,
|
||||
services: &Services,
|
||||
cx: Arc<SdkCallCx>,
|
||||
ictx: InterceptorCtx,
|
||||
) {
|
||||
let docs_service = services.docs.clone();
|
||||
let group_docs_service = services.group_docs.clone();
|
||||
|
||||
@@ -59,6 +72,7 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
||||
{
|
||||
let docs_service = docs_service.clone();
|
||||
let cx = cx.clone();
|
||||
let ictx = ictx.clone();
|
||||
module.set_native_fn(
|
||||
"collection",
|
||||
move |name: &str| -> Result<DocsHandle, Box<EvalAltResult>> {
|
||||
@@ -69,6 +83,7 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
||||
collection: name.to_string(),
|
||||
service: docs_service.clone(),
|
||||
cx: cx.clone(),
|
||||
ictx: ictx.clone(),
|
||||
})
|
||||
},
|
||||
);
|
||||
@@ -76,6 +91,7 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
||||
{
|
||||
let group_docs_service = group_docs_service.clone();
|
||||
let cx = cx.clone();
|
||||
let ictx = ictx.clone();
|
||||
module.set_native_fn(
|
||||
"shared_collection",
|
||||
move |name: &str| -> Result<GroupDocsHandle, Box<EvalAltResult>> {
|
||||
@@ -86,6 +102,7 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
||||
collection: name.to_string(),
|
||||
service: group_docs_service.clone(),
|
||||
cx: cx.clone(),
|
||||
ictx: ictx.clone(),
|
||||
})
|
||||
},
|
||||
);
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::bridge::block_on;
|
||||
use super::interceptor::InterceptorCtx;
|
||||
use picloud_shared::{
|
||||
FileMeta, FileUpdate, FilesService, GroupFilesService, NewFile, SdkCallCx, Services,
|
||||
};
|
||||
@@ -36,6 +37,10 @@ pub struct FilesHandle {
|
||||
collection: String,
|
||||
service: Arc<dyn FilesService>,
|
||||
cx: Arc<SdkCallCx>,
|
||||
// §9.4 M1 plumbing: carried so `create`/`update`/`delete` can run a before/
|
||||
// after hook in a later milestone (M9). Unused until then.
|
||||
#[allow(dead_code)]
|
||||
ictx: InterceptorCtx,
|
||||
}
|
||||
|
||||
/// §11.6 shared-collection handle, returned by `files::shared_collection(name)`.
|
||||
@@ -47,9 +52,17 @@ pub struct GroupFilesHandle {
|
||||
collection: String,
|
||||
service: Arc<dyn GroupFilesService>,
|
||||
cx: Arc<SdkCallCx>,
|
||||
// §9.4 M1 plumbing (see `FilesHandle`). Unused until M9.
|
||||
#[allow(dead_code)]
|
||||
ictx: InterceptorCtx,
|
||||
}
|
||||
|
||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||
pub(super) fn register(
|
||||
engine: &mut RhaiEngine,
|
||||
services: &Services,
|
||||
cx: Arc<SdkCallCx>,
|
||||
ictx: InterceptorCtx,
|
||||
) {
|
||||
let files_service = services.files.clone();
|
||||
let group_files_service = services.group_files.clone();
|
||||
|
||||
@@ -57,6 +70,7 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
||||
{
|
||||
let files_service = files_service.clone();
|
||||
let cx = cx.clone();
|
||||
let ictx = ictx.clone();
|
||||
module.set_native_fn(
|
||||
"collection",
|
||||
move |name: &str| -> Result<FilesHandle, Box<EvalAltResult>> {
|
||||
@@ -67,6 +81,7 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
||||
collection: name.to_string(),
|
||||
service: files_service.clone(),
|
||||
cx: cx.clone(),
|
||||
ictx: ictx.clone(),
|
||||
})
|
||||
},
|
||||
);
|
||||
@@ -74,6 +89,7 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
||||
{
|
||||
let group_files_service = group_files_service.clone();
|
||||
let cx = cx.clone();
|
||||
let ictx = ictx.clone();
|
||||
module.set_native_fn(
|
||||
"shared_collection",
|
||||
move |name: &str| -> Result<GroupFilesHandle, Box<EvalAltResult>> {
|
||||
@@ -84,6 +100,7 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
||||
collection: name.to_string(),
|
||||
service: group_files_service.clone(),
|
||||
cx: cx.clone(),
|
||||
ictx: ictx.clone(),
|
||||
})
|
||||
},
|
||||
);
|
||||
|
||||
@@ -38,6 +38,7 @@ use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||
use super::bridge::{
|
||||
block_on, dynamic_to_json_capped, json_to_dynamic, runtime_err, MAX_JSON_MATERIALIZE_BYTES,
|
||||
};
|
||||
use super::interceptor::InterceptorCtx;
|
||||
|
||||
/// Bridge-side defaults (the service clamps server-side too). The
|
||||
/// `MAX_*` ceilings stay `i64` because they're compared against the
|
||||
@@ -50,7 +51,16 @@ const MAX_REDIRECTS: i64 = 10;
|
||||
|
||||
const ALLOWED_OPT_KEYS: [&str; 4] = ["headers", "timeout_ms", "follow_redirects", "max_redirects"];
|
||||
|
||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||
// `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.
|
||||
pub(super) fn register(
|
||||
engine: &mut RhaiEngine,
|
||||
services: &Services,
|
||||
cx: Arc<SdkCallCx>,
|
||||
_ictx: InterceptorCtx,
|
||||
) {
|
||||
let svc = services.http.clone();
|
||||
let mut module = Module::new();
|
||||
|
||||
|
||||
@@ -22,10 +22,10 @@
|
||||
//! thread-local guard, set for the duration of the interceptor's synchronous
|
||||
//! execution, makes any nested op the interceptor performs bypass interception.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::sync::Arc;
|
||||
|
||||
use picloud_shared::{InterceptorResolution, InterceptorService, SdkCallCx};
|
||||
use picloud_shared::{InterceptorEntry, InterceptorService, ScriptId, SdkCallCx};
|
||||
use rhai::EvalAltResult;
|
||||
use serde_json::{json, Value as Json};
|
||||
use tokio::runtime::Handle as TokioHandle;
|
||||
@@ -35,12 +35,31 @@ use crate::sandbox::Limits;
|
||||
use crate::sdk::bridge::runtime_err;
|
||||
use crate::sdk::invoke::run_resolved_blocking;
|
||||
|
||||
/// Clone-cheap bundle of the §9.4 interceptor dependencies threaded into every
|
||||
/// SDK service `register` fn (M1). Carries the resolver, the `invoke()` re-entry
|
||||
/// engine back-reference, and the sandbox limits — everything `run_before`
|
||||
/// (and, from M3, `run_after`) needs. A few `Arc`s plus a small `Limits` copy.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct InterceptorCtx {
|
||||
pub interceptors: Arc<dyn InterceptorService>,
|
||||
pub self_engine: Option<Arc<Engine>>,
|
||||
pub limits: Limits,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// Re-entrancy depth: `> 0` while an interceptor script (and anything it
|
||||
/// synchronously invokes) is executing on this thread. The whole re-entry
|
||||
/// runs synchronously in the caller's `spawn_blocking` thread (same model
|
||||
/// as `invoke()`), so a thread-local is sufficient and correct.
|
||||
static IN_INTERCEPTOR: Cell<u32> = const { Cell::new(0) };
|
||||
|
||||
/// Identity cycle guard (M2): the `script_id`s of the interceptors currently
|
||||
/// on the synchronous run stack. A chain that would run an interceptor whose
|
||||
/// script is already executing is a cycle — we DENY rather than recurse.
|
||||
/// Precise (keyed by script id) and independent of the binary
|
||||
/// `IN_INTERCEPTOR` counter, which suppresses interception of an
|
||||
/// interceptor's OWN nested writes but does not track identity.
|
||||
static VISITED: RefCell<Vec<ScriptId>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
fn currently_in_interceptor() -> bool {
|
||||
@@ -62,17 +81,37 @@ impl Drop for ReentryGuard {
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the before-op interceptor for `(service, op)` if one is registered.
|
||||
/// Returns `Ok(())` to allow the operation (un-hooked, or the interceptor
|
||||
/// explicitly allowed it) or an `Err` runtime error to deny it (the caller must
|
||||
/// NOT then perform the write). `value` is the payload being written (`None`
|
||||
/// for delete).
|
||||
/// RAII for the identity cycle guard: push a `script_id` on enter, pop on drop
|
||||
/// (including the `?`/panic-unwind paths), so the visited set exactly tracks the
|
||||
/// interceptors on the current synchronous run stack.
|
||||
struct CycleGuard;
|
||||
impl CycleGuard {
|
||||
fn enter(id: ScriptId) -> Self {
|
||||
VISITED.with(|v| v.borrow_mut().push(id));
|
||||
Self
|
||||
}
|
||||
fn contains(id: ScriptId) -> bool {
|
||||
VISITED.with(|v| v.borrow().contains(&id))
|
||||
}
|
||||
}
|
||||
impl Drop for CycleGuard {
|
||||
fn drop(&mut self) {
|
||||
VISITED.with(|v| {
|
||||
v.borrow_mut().pop();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the before-op interceptor CHAIN for `(service, op)` if any are
|
||||
/// registered. Returns `Ok(())` to allow the operation (un-hooked, or every
|
||||
/// before-interceptor explicitly allowed it) or an `Err` runtime error to deny
|
||||
/// it (the caller must NOT then perform the write). A single deny
|
||||
/// short-circuits — the write is blocked. `value` is the payload being written
|
||||
/// (`None` for delete).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn run_before(
|
||||
interceptors: &Arc<dyn InterceptorService>,
|
||||
self_engine: Option<&Arc<Engine>>,
|
||||
ictx: &InterceptorCtx,
|
||||
cx: &Arc<SdkCallCx>,
|
||||
limits: Limits,
|
||||
service: &'static str,
|
||||
op: &'static str,
|
||||
collection: &str,
|
||||
@@ -89,47 +128,21 @@ pub(super) fn run_before(
|
||||
let handle = TokioHandle::try_current()
|
||||
.map_err(|e| runtime_err(&format!("{service} interceptor: no tokio runtime: {e}")))?;
|
||||
|
||||
// (1) Resolve the nearest interceptor, sealed to its declaring owner.
|
||||
let resolution = {
|
||||
let interceptors = interceptors.clone();
|
||||
// (1) Resolve the whole before+after chain, each entry sealed to its
|
||||
// declaring owner. `before` is ordered ancestor→app (an outer/group guard
|
||||
// runs first). `after` is unused this milestone.
|
||||
let chain = {
|
||||
let interceptors = ictx.interceptors.clone();
|
||||
let cx = cx.clone();
|
||||
handle
|
||||
.block_on(async move { interceptors.resolve_before(&cx, service, op).await })
|
||||
.block_on(async move { interceptors.resolve(&cx, service, op).await })
|
||||
.map_err(|e| runtime_err(&format!("{service}::{op} interceptor resolve: {e}")))?
|
||||
};
|
||||
let resolved = match resolution {
|
||||
// Un-hooked: the ONLY path that allows without running anything.
|
||||
InterceptorResolution::None => return Ok(()),
|
||||
// A guard is declared but its script is missing/disabled — fail closed.
|
||||
InterceptorResolution::Dangling(name) => {
|
||||
return Err(runtime_err(&format!(
|
||||
"{service}::{op} denied: interceptor script `{name}` is missing or disabled"
|
||||
)));
|
||||
}
|
||||
InterceptorResolution::Run(r) => r,
|
||||
};
|
||||
|
||||
// A guard is registered but the engine back-reference isn't installed (a
|
||||
// bare test engine without `set_self_weak`): we cannot run it, so DENY —
|
||||
// a resolvable guard we can't execute must not fail open. Production always
|
||||
// installs the back-ref.
|
||||
let Some(self_engine) = self_engine else {
|
||||
return Err(runtime_err(&format!(
|
||||
"{service}::{op} denied: interceptor `{}` cannot run (engine back-reference not installed)",
|
||||
resolved.name
|
||||
)));
|
||||
};
|
||||
|
||||
// Depth bound (shared with invoke / trigger fan-out).
|
||||
if cx.trigger_depth + 1 > limits.trigger_depth_max {
|
||||
return Err(runtime_err(&format!(
|
||||
"{service}::{op} interceptor `{}`: depth limit exceeded (max {})",
|
||||
resolved.name, limits.trigger_depth_max
|
||||
)));
|
||||
// Un-hooked: the ONLY path that allows without running anything.
|
||||
if chain.before.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// (2) Run the sealed script with the operation context as its request body,
|
||||
// under the re-entrancy guard so its own writes bypass interception.
|
||||
let payload = json!({
|
||||
"service": service,
|
||||
"action": op,
|
||||
@@ -139,35 +152,88 @@ pub(super) fn run_before(
|
||||
"caller_script_id": cx.script_id.to_string(),
|
||||
"caller_execution_id": cx.execution_id.to_string(),
|
||||
});
|
||||
let ret = {
|
||||
let _guard = ReentryGuard::enter();
|
||||
run_resolved_blocking(
|
||||
self_engine,
|
||||
cx,
|
||||
&resolved,
|
||||
payload,
|
||||
&format!("{service}::{op} interceptor `{}`", resolved.name),
|
||||
)?
|
||||
};
|
||||
|
||||
// Fail-closed verdict: allow ONLY on an explicit `#{ allowed: true }`. A
|
||||
// bare bool, a typo'd key, a non-bool `allowed`, a bare unit, or any other
|
||||
// shape DENIES — a control whose job is to deny must not allow by omission.
|
||||
if let Json::Object(m) = &ret {
|
||||
if m.get("allowed") == Some(&Json::Bool(true)) {
|
||||
return Ok(());
|
||||
// (2) Run each before-interceptor in order; the FIRST deny short-circuits.
|
||||
for entry in &chain.before {
|
||||
let resolved = match entry {
|
||||
// A guard is declared but its script is missing/disabled — fail closed.
|
||||
InterceptorEntry::Dangling(name) => {
|
||||
return Err(runtime_err(&format!(
|
||||
"{service}::{op} denied: interceptor script `{name}` is missing or disabled"
|
||||
)));
|
||||
}
|
||||
InterceptorEntry::Run(r) => &r.script,
|
||||
};
|
||||
|
||||
// Identity cycle guard: if this interceptor's script is already on the
|
||||
// run stack, running it again would recurse forever — DENY.
|
||||
if CycleGuard::contains(resolved.script_id) {
|
||||
return Err(runtime_err(&format!(
|
||||
"{service}::{op} denied: interceptor cycle detected: `{}`",
|
||||
resolved.name
|
||||
)));
|
||||
}
|
||||
|
||||
// A guard is registered but the engine back-reference isn't installed (a
|
||||
// bare test engine without `set_self_weak`): we cannot run it, so DENY —
|
||||
// a resolvable guard we can't execute must not fail open. Production
|
||||
// always installs the back-ref.
|
||||
let Some(self_engine) = ictx.self_engine.as_ref() else {
|
||||
return Err(runtime_err(&format!(
|
||||
"{service}::{op} denied: interceptor `{}` cannot run (engine back-reference not installed)",
|
||||
resolved.name
|
||||
)));
|
||||
};
|
||||
|
||||
// Depth bound (shared with invoke / trigger fan-out).
|
||||
if cx.trigger_depth + 1 > ictx.limits.trigger_depth_max {
|
||||
return Err(runtime_err(&format!(
|
||||
"{service}::{op} interceptor `{}`: depth limit exceeded (max {})",
|
||||
resolved.name, ictx.limits.trigger_depth_max
|
||||
)));
|
||||
}
|
||||
|
||||
// Run the sealed script with the operation context as its request body,
|
||||
// under the re-entrancy guard (its own writes bypass interception) and
|
||||
// the identity cycle guard (its own script id is on the stack).
|
||||
let ret = {
|
||||
let _reentry = ReentryGuard::enter();
|
||||
let _cycle = CycleGuard::enter(resolved.script_id);
|
||||
run_resolved_blocking(
|
||||
self_engine,
|
||||
cx,
|
||||
resolved,
|
||||
payload.clone(),
|
||||
&format!("{service}::{op} interceptor `{}`", resolved.name),
|
||||
)?
|
||||
};
|
||||
|
||||
// Fail-closed verdict: allow ONLY on an explicit `#{ allowed: true }`. A
|
||||
// bare bool, a typo'd key, a non-bool `allowed`, a bare unit, or any
|
||||
// other shape DENIES — a control whose job is to deny must not allow by
|
||||
// omission.
|
||||
match &ret {
|
||||
// Explicit allow → this entry passes; continue to the next.
|
||||
Json::Object(m) if m.get("allowed") == Some(&Json::Bool(true)) => {}
|
||||
Json::Object(m) => {
|
||||
let reason = m
|
||||
.get("reason")
|
||||
.and_then(Json::as_str)
|
||||
.unwrap_or("denied by interceptor");
|
||||
return Err(runtime_err(&format!(
|
||||
"{service}::{op} denied by interceptor `{}`: {reason}",
|
||||
resolved.name
|
||||
)));
|
||||
}
|
||||
_ => {
|
||||
return Err(runtime_err(&format!(
|
||||
"{service}::{op} denied by interceptor `{}`: no allow verdict returned",
|
||||
resolved.name
|
||||
)));
|
||||
}
|
||||
}
|
||||
let reason = m
|
||||
.get("reason")
|
||||
.and_then(Json::as_str)
|
||||
.unwrap_or("denied by interceptor");
|
||||
return Err(runtime_err(&format!(
|
||||
"{service}::{op} denied by interceptor `{}`: {reason}",
|
||||
resolved.name
|
||||
)));
|
||||
}
|
||||
Err(runtime_err(&format!(
|
||||
"{service}::{op} denied by interceptor `{}`: no allow verdict returned",
|
||||
resolved.name
|
||||
)))
|
||||
|
||||
// Every before-interceptor allowed.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -30,26 +30,23 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use picloud_shared::{GroupKvService, InterceptorService, KvService, SdkCallCx, Services};
|
||||
use picloud_shared::{GroupKvService, KvService, SdkCallCx, Services};
|
||||
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||
|
||||
use super::bridge::{
|
||||
block_on, dynamic_to_json_capped, json_to_dynamic, JsonSizeError, MAX_JSON_MATERIALIZE_BYTES,
|
||||
};
|
||||
use crate::engine::Engine;
|
||||
use crate::sandbox::Limits;
|
||||
use super::interceptor::InterceptorCtx;
|
||||
|
||||
/// Per-call handle captured by the Rhai SDK. Cheap to clone (a few Arcs plus an
|
||||
/// owned string). Carries the §9.4 interceptor deps so `set`/`delete` can run a
|
||||
/// owned string). Carries the §9.4 interceptor ctx so `set`/`delete` can run a
|
||||
/// before-op allow/deny hook (the resolver + the `invoke()` re-entry engine).
|
||||
#[derive(Clone)]
|
||||
pub struct KvHandle {
|
||||
collection: String,
|
||||
service: Arc<dyn KvService>,
|
||||
cx: Arc<SdkCallCx>,
|
||||
interceptors: Arc<dyn InterceptorService>,
|
||||
self_engine: Option<Arc<Engine>>,
|
||||
limits: Limits,
|
||||
ictx: InterceptorCtx,
|
||||
}
|
||||
|
||||
/// §11.6 shared-collection handle, returned by `kv::shared_collection(name)`. A distinct
|
||||
@@ -57,28 +54,24 @@ pub struct KvHandle {
|
||||
/// collections never grow triggers) and a script's choice of private-vs-shared
|
||||
/// scope is explicit. Routes through the `GroupKvService`, which resolves the
|
||||
/// owning group from `cx.app_id` (the script never names a group). Carries the
|
||||
/// §9.4 interceptor deps too so a `(kv, set/delete)` guard covers shared-
|
||||
/// §9.4 interceptor ctx too so a `(kv, set/delete)` guard covers shared-
|
||||
/// collection writes — otherwise the shared handle would be a silent bypass.
|
||||
#[derive(Clone)]
|
||||
pub struct GroupKvHandle {
|
||||
collection: String,
|
||||
service: Arc<dyn GroupKvService>,
|
||||
cx: Arc<SdkCallCx>,
|
||||
interceptors: Arc<dyn InterceptorService>,
|
||||
self_engine: Option<Arc<Engine>>,
|
||||
limits: Limits,
|
||||
ictx: InterceptorCtx,
|
||||
}
|
||||
|
||||
pub(super) fn register(
|
||||
engine: &mut RhaiEngine,
|
||||
services: &Services,
|
||||
cx: Arc<SdkCallCx>,
|
||||
limits: Limits,
|
||||
self_engine: Option<Arc<Engine>>,
|
||||
ictx: InterceptorCtx,
|
||||
) {
|
||||
let kv_service = services.kv.clone();
|
||||
let group_kv_service = services.group_kv.clone();
|
||||
let interceptors = services.interceptors.clone();
|
||||
|
||||
// `kv::collection(name)` / `kv::shared_collection(name)` — both constructors live in
|
||||
// the `kv` static module so the script-visible calls are `kv::collection`
|
||||
@@ -87,8 +80,7 @@ pub(super) fn register(
|
||||
{
|
||||
let kv_service = kv_service.clone();
|
||||
let cx = cx.clone();
|
||||
let interceptors = interceptors.clone();
|
||||
let self_engine = self_engine.clone();
|
||||
let ictx = ictx.clone();
|
||||
module.set_native_fn(
|
||||
"collection",
|
||||
move |name: &str| -> Result<KvHandle, Box<EvalAltResult>> {
|
||||
@@ -99,9 +91,7 @@ pub(super) fn register(
|
||||
collection: name.to_string(),
|
||||
service: kv_service.clone(),
|
||||
cx: cx.clone(),
|
||||
interceptors: interceptors.clone(),
|
||||
self_engine: self_engine.clone(),
|
||||
limits,
|
||||
ictx: ictx.clone(),
|
||||
})
|
||||
},
|
||||
);
|
||||
@@ -109,8 +99,7 @@ pub(super) fn register(
|
||||
{
|
||||
let group_kv_service = group_kv_service.clone();
|
||||
let cx = cx.clone();
|
||||
let interceptors = interceptors.clone();
|
||||
let self_engine = self_engine.clone();
|
||||
let ictx = ictx.clone();
|
||||
module.set_native_fn(
|
||||
"shared_collection",
|
||||
move |name: &str| -> Result<GroupKvHandle, Box<EvalAltResult>> {
|
||||
@@ -121,9 +110,7 @@ pub(super) fn register(
|
||||
collection: name.to_string(),
|
||||
service: group_kv_service.clone(),
|
||||
cx: cx.clone(),
|
||||
interceptors: interceptors.clone(),
|
||||
self_engine: self_engine.clone(),
|
||||
limits,
|
||||
ictx: ictx.clone(),
|
||||
})
|
||||
},
|
||||
);
|
||||
@@ -188,10 +175,8 @@ fn register_set(engine: &mut RhaiEngine) {
|
||||
// §9.4 before-op interceptor (allow/deny). A denial errors here and
|
||||
// the write below never runs.
|
||||
super::interceptor::run_before(
|
||||
&handle.interceptors,
|
||||
handle.self_engine.as_ref(),
|
||||
&handle.ictx,
|
||||
&handle.cx,
|
||||
handle.limits,
|
||||
"kv",
|
||||
"set",
|
||||
&handle.collection,
|
||||
@@ -244,10 +229,8 @@ fn register_delete(engine: &mut RhaiEngine) {
|
||||
"delete",
|
||||
|handle: &mut KvHandle, key: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||
super::interceptor::run_before(
|
||||
&handle.interceptors,
|
||||
handle.self_engine.as_ref(),
|
||||
&handle.ictx,
|
||||
&handle.cx,
|
||||
handle.limits,
|
||||
"kv",
|
||||
"delete",
|
||||
&handle.collection,
|
||||
@@ -271,10 +254,8 @@ fn group_run_before(
|
||||
value: Option<&serde_json::Value>,
|
||||
) -> Result<(), Box<EvalAltResult>> {
|
||||
super::interceptor::run_before(
|
||||
&handle.interceptors,
|
||||
handle.self_engine.as_ref(),
|
||||
&handle.ictx,
|
||||
&handle.cx,
|
||||
handle.limits,
|
||||
"kv",
|
||||
op,
|
||||
&handle.collection,
|
||||
|
||||
@@ -60,13 +60,21 @@ pub fn register_all(
|
||||
limits: Limits,
|
||||
self_engine: Option<Arc<Engine>>,
|
||||
) {
|
||||
kv::register(engine, services, cx.clone(), limits, self_engine.clone());
|
||||
docs::register(engine, services, cx.clone());
|
||||
// §9.4 M1: build the interceptor ctx once and thread it into every SDK
|
||||
// service that has (or will gain) a before/after hook. Only `kv` runs a
|
||||
// hook today; docs/files/queue/pubsub/http carry it for M7–M11.
|
||||
let ictx = interceptor::InterceptorCtx {
|
||||
interceptors: services.interceptors.clone(),
|
||||
self_engine: self_engine.clone(),
|
||||
limits,
|
||||
};
|
||||
kv::register(engine, services, cx.clone(), ictx.clone());
|
||||
docs::register(engine, services, cx.clone(), ictx.clone());
|
||||
dead_letters::register(engine, services, cx.clone());
|
||||
http::register(engine, services, cx.clone());
|
||||
files::register(engine, services, cx.clone());
|
||||
pubsub::register(engine, services, cx.clone());
|
||||
queue::register(engine, services, cx.clone());
|
||||
http::register(engine, services, cx.clone(), ictx.clone());
|
||||
files::register(engine, services, cx.clone(), ictx.clone());
|
||||
pubsub::register(engine, services, cx.clone(), ictx.clone());
|
||||
queue::register(engine, services, cx.clone(), ictx);
|
||||
retry::register(engine, services, cx.clone());
|
||||
secrets::register(engine, services, cx.clone());
|
||||
vars::register(engine, services, cx.clone());
|
||||
|
||||
@@ -25,8 +25,14 @@ use serde_json::Value as Json;
|
||||
use tokio::runtime::Handle as TokioHandle;
|
||||
|
||||
use super::bridge::{block_on, MAX_JSON_MATERIALIZE_BYTES};
|
||||
use super::interceptor::InterceptorCtx;
|
||||
|
||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||
pub(super) fn register(
|
||||
engine: &mut RhaiEngine,
|
||||
services: &Services,
|
||||
cx: Arc<SdkCallCx>,
|
||||
ictx: InterceptorCtx,
|
||||
) {
|
||||
let svc = services.pubsub.clone();
|
||||
let mut module = Module::new();
|
||||
{
|
||||
@@ -77,6 +83,7 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
||||
{
|
||||
let group_svc = services.group_pubsub.clone();
|
||||
let cx = cx.clone();
|
||||
let ictx = ictx.clone();
|
||||
module.set_native_fn(
|
||||
"shared_topic",
|
||||
move |name: &str| -> Result<GroupTopicHandle, Box<EvalAltResult>> {
|
||||
@@ -87,6 +94,7 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
||||
namespace: name.to_string(),
|
||||
service: group_svc.clone(),
|
||||
cx: cx.clone(),
|
||||
ictx: ictx.clone(),
|
||||
})
|
||||
},
|
||||
);
|
||||
@@ -104,6 +112,10 @@ pub struct GroupTopicHandle {
|
||||
namespace: String,
|
||||
service: Arc<dyn picloud_shared::GroupPubsubService>,
|
||||
cx: Arc<SdkCallCx>,
|
||||
// §9.4 M1 plumbing: carried so `publish` can run a before/after hook in a
|
||||
// later milestone (M11). Unused until then.
|
||||
#[allow(dead_code)]
|
||||
ictx: InterceptorCtx,
|
||||
}
|
||||
|
||||
fn shared_publish(
|
||||
|
||||
@@ -28,8 +28,14 @@ use serde_json::Value as Json;
|
||||
use tokio::runtime::Handle as TokioHandle;
|
||||
|
||||
use super::bridge::MAX_JSON_MATERIALIZE_BYTES;
|
||||
use super::interceptor::InterceptorCtx;
|
||||
|
||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||
pub(super) fn register(
|
||||
engine: &mut RhaiEngine,
|
||||
services: &Services,
|
||||
cx: Arc<SdkCallCx>,
|
||||
ictx: InterceptorCtx,
|
||||
) {
|
||||
let svc = services.queue.clone();
|
||||
let mut module = Module::new();
|
||||
|
||||
@@ -100,6 +106,7 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
||||
{
|
||||
let group_svc = services.group_queue.clone();
|
||||
let cx = cx.clone();
|
||||
let ictx = ictx.clone();
|
||||
module.set_native_fn(
|
||||
"shared_collection",
|
||||
move |name: &str| -> Result<GroupQueueHandle, Box<EvalAltResult>> {
|
||||
@@ -110,6 +117,7 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
||||
collection: name.to_string(),
|
||||
service: group_svc.clone(),
|
||||
cx: cx.clone(),
|
||||
ictx: ictx.clone(),
|
||||
})
|
||||
},
|
||||
);
|
||||
@@ -126,6 +134,10 @@ pub struct GroupQueueHandle {
|
||||
collection: String,
|
||||
service: Arc<dyn GroupQueueService>,
|
||||
cx: Arc<SdkCallCx>,
|
||||
// §9.4 M1 plumbing: carried so `enqueue` can run a before/after hook in a
|
||||
// later milestone (M10). Unused until then.
|
||||
#[allow(dead_code)]
|
||||
ictx: InterceptorCtx,
|
||||
}
|
||||
|
||||
fn group_enqueue(
|
||||
|
||||
Reference in New Issue
Block a user