Make `import` resolution lexical (§5.5): an inherited group script's imports resolve against the module set at its OWN defining node, walking up from there — a leaf app can't shadow them, and a group script behaves identically under every inheriting app. Mechanism — Rhai's `_source` carries the importing AST's origin tag: - The entry script has no source tag → resolve from `default_origin` (its defining node, threaded in via C2). - Each resolved module's AST source is set to `encode(module.owner())` before `eval_ast_as_new`, so its nested `import`s carry the module's own node as `_source` and resolve from there — lexical chaining. - `encode_origin`/`decode_origin` map a `ScriptOwner` to/from `app:<uuid>` / `group:<uuid>`. Cache rekeyed from `(app_id, name)` to the resolved `ScriptId`: a compiled module is lexically self-contained, so its body is a pure function of `(script_id, updated_at)` — this also dedupes a shared group module across inheriting apps and keeps same-named cross-app modules distinct. Adds two executor-core unit tests (origin-keyed fake) proving the trust boundary: a group module's import binds the group's module even when an app-owned module of the same name exists; `ScriptOwner` gains `Hash`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
183 lines
6.7 KiB
Rust
183 lines
6.7 KiB
Rust
use chrono::{DateTime, Utc};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::{AppId, GroupId, ScriptId, ScriptSandbox};
|
|
|
|
/// Semantic role of a script (v1.1.3).
|
|
///
|
|
/// `Endpoint` scripts have an executable entry point — they bind to HTTP
|
|
/// routes and act as trigger handlers. `Module` scripts are libraries of
|
|
/// `fn`/`const` declarations imported by other scripts via Rhai's
|
|
/// `import "<name>" as <alias>;` syntax. Modules cannot be invoked
|
|
/// directly: route binding and trigger creation reject `Module` targets.
|
|
///
|
|
/// Serialized as `"endpoint"` / `"module"` so the wire shape is the
|
|
/// same string the SQL `CHECK (kind IN ('endpoint','module'))`
|
|
/// constraint enforces.
|
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum ScriptKind {
|
|
#[default]
|
|
Endpoint,
|
|
Module,
|
|
}
|
|
|
|
impl ScriptKind {
|
|
/// Wire / SQL representation. Inverse of `parse_str`.
|
|
#[must_use]
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::Endpoint => "endpoint",
|
|
Self::Module => "module",
|
|
}
|
|
}
|
|
|
|
/// Parse the canonical wire / SQL form. Returns `None` for any
|
|
/// other input; callers map that to a 400 / `ValidationError`.
|
|
/// Named `parse_str` (not `from_str`) to dodge the
|
|
/// `std::str::FromStr` lint without taking on the trait's
|
|
/// `Result<Self, Self::Err>` shape that this caller doesn't need.
|
|
#[must_use]
|
|
pub fn parse_str(s: &str) -> Option<Self> {
|
|
match s {
|
|
"endpoint" => Some(Self::Endpoint),
|
|
"module" => Some(Self::Module),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod kind_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn default_is_endpoint() {
|
|
assert_eq!(ScriptKind::default(), ScriptKind::Endpoint);
|
|
}
|
|
|
|
#[test]
|
|
fn round_trips_through_serde_lowercase() {
|
|
assert_eq!(
|
|
serde_json::to_string(&ScriptKind::Endpoint).unwrap(),
|
|
"\"endpoint\""
|
|
);
|
|
assert_eq!(
|
|
serde_json::to_string(&ScriptKind::Module).unwrap(),
|
|
"\"module\""
|
|
);
|
|
assert_eq!(
|
|
serde_json::from_str::<ScriptKind>("\"endpoint\"").unwrap(),
|
|
ScriptKind::Endpoint
|
|
);
|
|
assert_eq!(
|
|
serde_json::from_str::<ScriptKind>("\"module\"").unwrap(),
|
|
ScriptKind::Module
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_str_round_trip() {
|
|
for k in [ScriptKind::Endpoint, ScriptKind::Module] {
|
|
assert_eq!(ScriptKind::parse_str(k.as_str()), Some(k));
|
|
}
|
|
assert_eq!(ScriptKind::parse_str("invalid"), None);
|
|
assert_eq!(ScriptKind::parse_str(""), None);
|
|
}
|
|
}
|
|
|
|
/// A user-uploaded Rhai script and its execution configuration.
|
|
///
|
|
/// This is the canonical representation that flows between manager (storage),
|
|
/// orchestrator (dispatch), and executor (run). It must stay serializable
|
|
/// because in cluster mode it crosses process boundaries on every invocation.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Script {
|
|
pub id: ScriptId,
|
|
/// Owning app, when app-owned. Set on create, immutable thereafter — a
|
|
/// "move to another app" is a copy+delete, not an in-place edit
|
|
/// (snapshot semantics — see blueprint §11.5).
|
|
///
|
|
/// Phase 4 (v1.2 Hierarchies) made script ownership **polymorphic**:
|
|
/// exactly one of `app_id` / `group_id` is set (DB CHECK + [`ScriptOwner`]).
|
|
/// A group-owned script (`app_id: None`, `group_id: Some`) is a template
|
|
/// inherited by every descendant app — it has no single app, so the
|
|
/// **execution context** app is supplied by the route/trigger/caller that
|
|
/// invoked it, never read off the script. Reading `app_id` to mean "the
|
|
/// app this runs under" is therefore a bug for group scripts; use the
|
|
/// invoking surface's app_id instead.
|
|
#[serde(default)]
|
|
pub app_id: Option<AppId>,
|
|
/// Owning group, when group-owned (Phase 4). Mutually exclusive with
|
|
/// `app_id`. The script is resolved by name, nearest-owner-wins, down the
|
|
/// `apps.group_id → groups.parent_id` chain (CoW: an app's own script of
|
|
/// the same name shadows the inherited one).
|
|
#[serde(default)]
|
|
pub group_id: Option<GroupId>,
|
|
pub name: String,
|
|
pub description: Option<String>,
|
|
pub version: i32,
|
|
pub source: String,
|
|
|
|
/// `Endpoint` (default; the only kind v1.0 through v1.1.2 supported)
|
|
/// or `Module` (v1.1.3 — imported by other scripts, never bound
|
|
/// directly to a route or trigger).
|
|
#[serde(default)]
|
|
pub kind: ScriptKind,
|
|
|
|
pub timeout_seconds: u32,
|
|
|
|
/// Per-script overrides for Rhai sandbox limits. Empty = platform
|
|
/// defaults. Values are admin-ceiling-clamped at write time.
|
|
#[serde(default)]
|
|
pub sandbox: ScriptSandbox,
|
|
|
|
/// **v1.3+ advisory only.** Real heap limits require OS-level
|
|
/// isolation (cgroups, process limits) which lands with cluster-mode
|
|
/// executor sandboxing. The field stays in the schema so we don't
|
|
/// have to add it back when that's built.
|
|
pub memory_limit_mb: u32,
|
|
|
|
/// Three-state lifecycle (§4.3): `false` means deployed-but-inert — the
|
|
/// script is not invocable (route 404s, trigger doesn't fire) but stays
|
|
/// as desired state (not pruned). Defaults `true`.
|
|
#[serde(default = "crate::default_true")]
|
|
pub enabled: bool,
|
|
|
|
pub created_at: DateTime<Utc>,
|
|
pub updated_at: DateTime<Utc>,
|
|
}
|
|
|
|
/// Who owns a script (Phase 4). Exactly one owner — the DB enforces it with
|
|
/// a `CHECK ((group_id IS NULL) <> (app_id IS NULL))`; this enum is the
|
|
/// in-memory witness of that invariant so call sites can `match` exhaustively
|
|
/// instead of juggling two `Option`s.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
pub enum ScriptOwner {
|
|
App(AppId),
|
|
Group(GroupId),
|
|
}
|
|
|
|
impl Script {
|
|
/// The script's owner, reconstructed from the polymorphic columns.
|
|
/// A row that violates the exactly-one invariant (both/neither set —
|
|
/// impossible under the CHECK) is reported as whichever is present,
|
|
/// preferring the app, so a corrupt row never panics on the hot path.
|
|
#[must_use]
|
|
pub fn owner(&self) -> Option<ScriptOwner> {
|
|
match (self.app_id, self.group_id) {
|
|
(Some(a), _) => Some(ScriptOwner::App(a)),
|
|
(None, Some(g)) => Some(ScriptOwner::Group(g)),
|
|
(None, None) => None,
|
|
}
|
|
}
|
|
|
|
/// True iff this script is owned by `app` directly (not via a group).
|
|
/// The cheap, app-owned-only ownership check — group inheritance is
|
|
/// resolved separately (chain-membership), never by this method.
|
|
#[must_use]
|
|
pub fn is_owned_by_app(&self, app: AppId) -> bool {
|
|
self.app_id == Some(app)
|
|
}
|
|
}
|