feat(modules): lexical import resolver — seal imports to defining node (Phase 4b C3)
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>
This commit is contained in:
@@ -1,25 +1,33 @@
|
|||||||
//! `PicloudModuleResolver` — the v1.1.3 per-app Rhai module resolver.
|
//! `PicloudModuleResolver` — the Rhai module resolver (v1.1.3; made
|
||||||
|
//! origin-aware / lexical in v1.2 Phase 4b).
|
||||||
//!
|
//!
|
||||||
//! Replaces `DummyModuleResolver` in `Engine::build_engine`. Constructed
|
//! Replaces `DummyModuleResolver` in `Engine::build_engine`. Constructed
|
||||||
//! fresh per `Engine::execute` call: holds an `Arc<SdkCallCx>` so every
|
//! fresh per `Engine::execute` call.
|
||||||
//! `import "<name>"` request resolves against the calling app
|
//!
|
||||||
//! (`cx.app_id`). The script-side `name` argument carries no `app_id`
|
//! **Lexical resolution (§5.5).** An `import "<name>"` resolves against the
|
||||||
//! — that's the load-bearing cross-app isolation property.
|
//! module set visible at the **importing script's own defining node**,
|
||||||
|
//! walking up its group chain — not the inheriting app's effective view.
|
||||||
|
//! The importing node is read from Rhai's `_source` (the importing AST's
|
||||||
|
//! source tag, which the resolver sets to each module's owner) and falls
|
||||||
|
//! back to `default_origin` (the entry script's defining node) for the
|
||||||
|
//! top-level AST. So an inherited group script's imports seal to the group
|
||||||
|
//! (a leaf can't shadow them — the trust boundary), while every SDK call
|
||||||
|
//! *inside* a module still scopes to the inheriting app via `cx.app_id`.
|
||||||
//!
|
//!
|
||||||
//! Three runtime invariants are enforced:
|
//! Three runtime invariants are enforced:
|
||||||
//!
|
//!
|
||||||
//! 1. **Cross-app isolation** — `ModuleSource::lookup` is called with
|
//! 1. **Lexical isolation** — `ModuleSource::resolve` is keyed by the
|
||||||
//! `&cx`; the Postgres impl scopes by `cx.app_id` (never by a
|
//! importing node's owner (a trusted dispatch-derived value), never a
|
||||||
//! script-passed argument).
|
//! script-passed argument.
|
||||||
//! 2. **Cycle detection** — an in-progress-imports stack rejects
|
//! 2. **Cycle detection** — an in-progress-imports stack rejects
|
||||||
//! `A → B → A` with `ErrorInModule(... circular import detected ...)`.
|
//! `A → B → A` with `ErrorInModule(... circular import detected ...)`.
|
||||||
//! 3. **Depth limit** — guards against deep but acyclic chains
|
//! 3. **Depth limit** — guards against deep but acyclic chains
|
||||||
//! (default 8, override via `PICLOUD_MODULE_IMPORT_DEPTH_MAX`).
|
//! (default 8, override via `PICLOUD_MODULE_IMPORT_DEPTH_MAX`).
|
||||||
//!
|
//!
|
||||||
//! Compiled modules are cached per `(app_id, name)` and invalidated by
|
//! Compiled modules are cached by resolved `ScriptId` (a compiled module
|
||||||
//! `updated_at` change — no explicit pub/sub. The cache is owned by
|
//! is lexically self-contained) and invalidated by `updated_at` change —
|
||||||
//! `Engine` and shared across calls; only the resolver state (stack,
|
//! no explicit pub/sub. The cache is owned by `Engine` and shared across
|
||||||
//! depth) is per-call.
|
//! calls; only the resolver state (stack, depth) is per-call.
|
||||||
|
|
||||||
use std::num::NonZeroUsize;
|
use std::num::NonZeroUsize;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
@@ -27,7 +35,7 @@ use std::sync::{Arc, Mutex};
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use lru::LruCache;
|
use lru::LruCache;
|
||||||
use picloud_shared::{
|
use picloud_shared::{
|
||||||
AppId, ModuleSource, ModuleSourceError, ScriptOwner, SdkCallCx, ValidatedScript,
|
ModuleSource, ModuleSourceError, ScriptId, ScriptOwner, SdkCallCx, ValidatedScript,
|
||||||
};
|
};
|
||||||
use rhai::module_resolvers::ModuleResolver;
|
use rhai::module_resolvers::ModuleResolver;
|
||||||
use rhai::{Engine as RhaiEngine, EvalAltResult, Module, Position, Shared, AST};
|
use rhai::{Engine as RhaiEngine, EvalAltResult, Module, Position, Shared, AST};
|
||||||
@@ -37,10 +45,38 @@ use rhai::{Engine as RhaiEngine, EvalAltResult, Module, Position, Shared, AST};
|
|||||||
/// `sync` feature that the workspace pins.
|
/// `sync` feature that the workspace pins.
|
||||||
type SharedRhaiModule = Shared<Module>;
|
type SharedRhaiModule = Shared<Module>;
|
||||||
|
|
||||||
/// Cache key: `(app_id, module name)`. v1.1.3 enforces module names as
|
/// Cache key: the resolved module's `ScriptId` (Phase 4b). A compiled
|
||||||
/// a conservative identifier shape (migration 0015 `scripts_module_name_shape`
|
/// module is lexically self-contained (its nested imports resolve from
|
||||||
/// CHECK) so the `String` here is bounded by ~64 bytes.
|
/// its own defining node, baked in at compile time), so the *body* is a
|
||||||
pub type ModuleCacheKey = (AppId, String);
|
/// pure function of `(script_id, updated_at)` — independent of which
|
||||||
|
/// script imported it. Keying by id also dedupes a shared group module
|
||||||
|
/// across every inheriting app, and keeps cross-app modules of the same
|
||||||
|
/// name distinct (distinct ids).
|
||||||
|
pub type ModuleCacheKey = ScriptId;
|
||||||
|
|
||||||
|
/// Encode a defining node as an AST `source` tag (`app:<uuid>` /
|
||||||
|
/// `group:<uuid>`). Set on a resolved module's AST so its own nested
|
||||||
|
/// `import`s carry *its* node as Rhai's `_source` — the lexical chaining
|
||||||
|
/// that seals each module's imports to where it was authored (§5.5).
|
||||||
|
fn encode_origin(owner: ScriptOwner) -> String {
|
||||||
|
match owner {
|
||||||
|
ScriptOwner::App(a) => format!("app:{a}"),
|
||||||
|
ScriptOwner::Group(g) => format!("group:{g}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inverse of [`encode_origin`]. `None` for an unrecognised tag (e.g. a
|
||||||
|
/// source set by something other than this resolver) — the caller then
|
||||||
|
/// falls back to the resolver's `default_origin`.
|
||||||
|
fn decode_origin(s: &str) -> Option<ScriptOwner> {
|
||||||
|
let (kind, id) = s.split_once(':')?;
|
||||||
|
let uuid = uuid::Uuid::parse_str(id).ok()?;
|
||||||
|
match kind {
|
||||||
|
"app" => Some(ScriptOwner::App(uuid.into())),
|
||||||
|
"group" => Some(ScriptOwner::Group(uuid.into())),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Cache value: the freshness comparator + the compiled module Rhai
|
/// Cache value: the freshness comparator + the compiled module Rhai
|
||||||
/// hands to importing scripts. Cloning the `Shared<Module>` is an Arc bump.
|
/// hands to importing scripts. Cloning the `Shared<Module>` is an Arc bump.
|
||||||
@@ -67,17 +103,19 @@ pub fn new_module_cache(capacity: usize) -> Arc<ModuleCache> {
|
|||||||
|
|
||||||
/// The v1.1.3 module resolver. One per `Engine::execute` call.
|
/// The v1.1.3 module resolver. One per `Engine::execute` call.
|
||||||
pub struct PicloudModuleResolver {
|
pub struct PicloudModuleResolver {
|
||||||
/// Backend the resolver consults for `(app_id, name)`. The bridge
|
/// Backend the resolver consults to resolve a module name against an
|
||||||
/// runs Rhai's sync `resolve()` and the async `lookup()` together
|
/// origin node's chain. The bridge runs Rhai's sync `resolve()` and the
|
||||||
/// via `tokio::runtime::Handle::block_on(...)` — safe because
|
/// async `ModuleSource::resolve()` together via
|
||||||
|
/// `tokio::runtime::Handle::block_on(...)` — safe because
|
||||||
/// `LocalExecutorClient` runs `Engine::execute` inside
|
/// `LocalExecutorClient` runs `Engine::execute` inside
|
||||||
/// `spawn_blocking`, which puts us on a Tokio blocking thread
|
/// `spawn_blocking`, which puts us on a Tokio blocking thread
|
||||||
/// that still carries a `Handle`.
|
/// that still carries a `Handle`.
|
||||||
source: Arc<dyn ModuleSource>,
|
source: Arc<dyn ModuleSource>,
|
||||||
|
|
||||||
/// Calling context. `cx.app_id` is the cross-app isolation
|
/// Calling context. `cx.app_id` is the execution boundary every SDK
|
||||||
/// boundary; the resolver passes `&cx` to every `ModuleSource`
|
/// call inside a module scopes to; the resolver keeps it for diagnostic
|
||||||
/// call so the backend can scope its queries.
|
/// logging. (Module *resolution* is keyed by the importing node's owner,
|
||||||
|
/// not `cx.app_id` — see the module docs.)
|
||||||
cx: Arc<SdkCallCx>,
|
cx: Arc<SdkCallCx>,
|
||||||
|
|
||||||
/// Compiled-module cache. Shared across executions; invalidated
|
/// Compiled-module cache. Shared across executions; invalidated
|
||||||
@@ -234,7 +272,7 @@ impl ModuleResolver for PicloudModuleResolver {
|
|||||||
fn resolve(
|
fn resolve(
|
||||||
&self,
|
&self,
|
||||||
engine: &RhaiEngine,
|
engine: &RhaiEngine,
|
||||||
_source: Option<&str>,
|
importer_source: Option<&str>,
|
||||||
path: &str,
|
path: &str,
|
||||||
pos: Position,
|
pos: Position,
|
||||||
) -> Result<SharedRhaiModule, Box<EvalAltResult>> {
|
) -> Result<SharedRhaiModule, Box<EvalAltResult>> {
|
||||||
@@ -329,12 +367,16 @@ impl ModuleResolver for PicloudModuleResolver {
|
|||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Lexical resolution (§5.5): resolve `path` against the importing
|
// Lexical resolution (§5.5): resolve `path` against the *importing
|
||||||
// node's chain. For the entry script that's `default_origin` (its
|
// node's* chain. Rhai gives `_source` = the importing AST's source
|
||||||
// defining node); nested-import origin via `_source` lands in C3.
|
// tag — set to the module's defining node for a nested import, or
|
||||||
// An app-rooted walk reaches ancestor group modules; a group-rooted
|
// unset (`None`) for the top-level entry script, where we fall back
|
||||||
// walk is sealed from app modules below it (the trust boundary).
|
// to `default_origin` (the executing script's node). An app-rooted
|
||||||
let origin = self.default_origin;
|
// walk reaches ancestor group modules; a group-rooted walk is sealed
|
||||||
|
// from app modules below it (the trust boundary).
|
||||||
|
let origin = importer_source
|
||||||
|
.and_then(decode_origin)
|
||||||
|
.unwrap_or(self.default_origin);
|
||||||
let lookup_result: Result<Option<picloud_shared::ModuleScript>, ModuleSourceError> =
|
let lookup_result: Result<Option<picloud_shared::ModuleScript>, ModuleSourceError> =
|
||||||
tokio::task::block_in_place(|| handle.block_on(self.source.resolve(origin, path)));
|
tokio::task::block_in_place(|| handle.block_on(self.source.resolve(origin, path)));
|
||||||
|
|
||||||
@@ -371,8 +413,10 @@ impl ModuleResolver for PicloudModuleResolver {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Cache lookup: hit only if both key matches AND updated_at
|
// Cache lookup: hit only if both key matches AND updated_at
|
||||||
// matches (cache is invalidated lazily on version change).
|
// matches (cache is invalidated lazily on version change). Keyed by
|
||||||
let cache_key = (self.cx.app_id, path.to_string());
|
// the resolved module's id (lexically self-contained — see
|
||||||
|
// `ModuleCacheKey`).
|
||||||
|
let cache_key = module_row.script_id;
|
||||||
{
|
{
|
||||||
let mut cache = self.cache.lock().expect("module cache lock poisoned");
|
let mut cache = self.cache.lock().expect("module cache lock poisoned");
|
||||||
if let Some(cached) = cache.get(&cache_key) {
|
if let Some(cached) = cache.get(&cache_key) {
|
||||||
@@ -405,7 +449,7 @@ impl ModuleResolver for PicloudModuleResolver {
|
|||||||
// already been gated at create-time (admin endpoint runs
|
// already been gated at create-time (admin endpoint runs
|
||||||
// `validate_module`), but we revalidate here to catch DB-direct
|
// `validate_module`), but we revalidate here to catch DB-direct
|
||||||
// inserts that bypass the API surface.
|
// inserts that bypass the API surface.
|
||||||
let ast = engine.compile(&module_row.source).map_err(|e| {
|
let mut ast = engine.compile(&module_row.source).map_err(|e| {
|
||||||
// Wrap as an ErrorRuntime to preserve the parse message
|
// Wrap as an ErrorRuntime to preserve the parse message
|
||||||
// text without trying to reconstruct rhai's internal
|
// text without trying to reconstruct rhai's internal
|
||||||
// ParseErrorType variant (which would require matching on
|
// ParseErrorType variant (which would require matching on
|
||||||
@@ -420,6 +464,15 @@ impl ModuleResolver for PicloudModuleResolver {
|
|||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
// Seal this module's own imports to its defining node (§5.5): tag
|
||||||
|
// the AST source with the resolved module's owner so that when
|
||||||
|
// `eval_ast_as_new` evaluates its nested `import`s, Rhai hands us
|
||||||
|
// that tag as `_source` and we resolve them lexically from *here*,
|
||||||
|
// not from the original importer. Fall back to the lookup origin
|
||||||
|
// for a corrupt owner row (never reached under the DB CHECK).
|
||||||
|
let module_owner = module_row.owner().unwrap_or(origin);
|
||||||
|
ast.set_source(encode_origin(module_owner));
|
||||||
|
|
||||||
if let Err(msg) = Self::check_module_shape(&ast, path) {
|
if let Err(msg) = Self::check_module_shape(&ast, path) {
|
||||||
return Err(Box::new(EvalAltResult::ErrorInModule(
|
return Err(Box::new(EvalAltResult::ErrorInModule(
|
||||||
path.to_string(),
|
path.to_string(),
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ use async_trait::async_trait;
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
|
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
|
||||||
use picloud_shared::{
|
use picloud_shared::{
|
||||||
AppId, ExecutionId, ModuleScript, ModuleSource, ModuleSourceError, NoopDeadLetterService,
|
AppId, ExecutionId, GroupId, ModuleScript, ModuleSource, ModuleSourceError,
|
||||||
NoopDocsService, NoopEventEmitter, NoopHttpService, NoopKvService, RequestId, ScriptId,
|
NoopDeadLetterService, NoopDocsService, NoopEventEmitter, NoopHttpService, NoopKvService,
|
||||||
ScriptOwner, ScriptSandbox, Services,
|
RequestId, ScriptId, ScriptOwner, ScriptSandbox, Services,
|
||||||
};
|
};
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
@@ -609,3 +609,130 @@ fn validate_endpoint_skips_dynamic_imports_in_imports_list() {
|
|||||||
v.imports
|
v.imports
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Phase 4b — lexical (origin-aware) resolution.
|
||||||
|
//
|
||||||
|
// `LexicalModuleSource` keys modules by their *exact* defining node, with no
|
||||||
|
// chain walk. That's enough to prove the resolver passes the RIGHT origin:
|
||||||
|
// `default_origin` for the entry AST, and each module's own owner (decoded
|
||||||
|
// from Rhai's `_source`) for its nested imports. The chain-walk + group-tree
|
||||||
|
// semantics are covered end-to-end by the CLI journey tests against Postgres.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct LexicalModuleSource {
|
||||||
|
table: Mutex<HashMap<(ScriptOwner, String), ModuleScript>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LexicalModuleSource {
|
||||||
|
fn new() -> Arc<Self> {
|
||||||
|
Arc::new(Self::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn put(self: &Arc<Self>, owner: ScriptOwner, name: &str, source: &str) {
|
||||||
|
let (app_id, group_id) = match owner {
|
||||||
|
ScriptOwner::App(a) => (Some(a), None),
|
||||||
|
ScriptOwner::Group(g) => (None, Some(g)),
|
||||||
|
};
|
||||||
|
self.table.lock().await.insert(
|
||||||
|
(owner, name.to_string()),
|
||||||
|
ModuleScript {
|
||||||
|
script_id: ScriptId::new(),
|
||||||
|
app_id,
|
||||||
|
group_id,
|
||||||
|
name: name.to_string(),
|
||||||
|
source: source.to_string(),
|
||||||
|
updated_at: Utc::now(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ModuleSource for LexicalModuleSource {
|
||||||
|
async fn resolve(
|
||||||
|
&self,
|
||||||
|
origin: ScriptOwner,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||||
|
Ok(self
|
||||||
|
.table
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.get(&(origin, name.to_string()))
|
||||||
|
.cloned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn req_with_owner(app_id: AppId, owner: ScriptOwner) -> ExecRequest {
|
||||||
|
let mut r = req(app_id);
|
||||||
|
r.script_owner = Some(owner);
|
||||||
|
r
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A group module's `import` resolves from the GROUP (its own defining
|
||||||
|
/// node), not the inheriting app — even when an app-owned module of the
|
||||||
|
/// same name exists. This is the §5.5 trust boundary: a leaf can't shadow
|
||||||
|
/// a module an inherited group script depends on.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn lexical_nested_import_seals_to_module_owner() {
|
||||||
|
let source = LexicalModuleSource::new();
|
||||||
|
let app = AppId::new();
|
||||||
|
let group = GroupId::new();
|
||||||
|
|
||||||
|
// Two "inner" modules: the group's (real) and the app's (a trap).
|
||||||
|
source
|
||||||
|
.put(ScriptOwner::Group(group), "inner", "fn v() { 42 }")
|
||||||
|
.await;
|
||||||
|
source
|
||||||
|
.put(ScriptOwner::App(app), "inner", "fn v() { 999 }")
|
||||||
|
.await;
|
||||||
|
// The group's "outer" imports "inner" — must bind the group's inner.
|
||||||
|
source
|
||||||
|
.put(
|
||||||
|
ScriptOwner::Group(group),
|
||||||
|
"outer",
|
||||||
|
r#"import "inner" as i; fn val() { i::v() }"#,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let engine = engine_with(source.clone());
|
||||||
|
// Entry script runs as the inherited GROUP script (default_origin = group).
|
||||||
|
let resp = engine
|
||||||
|
.execute(
|
||||||
|
r#"import "outer" as o; o::val()"#,
|
||||||
|
req_with_owner(app, ScriptOwner::Group(group)),
|
||||||
|
)
|
||||||
|
.expect("should execute");
|
||||||
|
assert_eq!(
|
||||||
|
resp.body,
|
||||||
|
serde_json::json!(42),
|
||||||
|
"group module's import must seal to the group's `inner`, not the app's trap"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same registry, entered as an APP-owned script, reaches the app's
|
||||||
|
/// module — proving the fake distinguishes origins and the entry uses
|
||||||
|
/// `default_origin`.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn lexical_entry_origin_selects_app_module() {
|
||||||
|
let source = LexicalModuleSource::new();
|
||||||
|
let app = AppId::new();
|
||||||
|
let group = GroupId::new();
|
||||||
|
source
|
||||||
|
.put(ScriptOwner::Group(group), "inner", "fn v() { 42 }")
|
||||||
|
.await;
|
||||||
|
source
|
||||||
|
.put(ScriptOwner::App(app), "inner", "fn v() { 999 }")
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let engine = engine_with(source.clone());
|
||||||
|
let resp = engine
|
||||||
|
.execute(
|
||||||
|
r#"import "inner" as i; i::v()"#,
|
||||||
|
req_with_owner(app, ScriptOwner::App(app)),
|
||||||
|
)
|
||||||
|
.expect("should execute");
|
||||||
|
assert_eq!(resp.body, serde_json::json!(999));
|
||||||
|
}
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ pub struct Script {
|
|||||||
/// a `CHECK ((group_id IS NULL) <> (app_id IS NULL))`; this enum is the
|
/// 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
|
/// in-memory witness of that invariant so call sites can `match` exhaustively
|
||||||
/// instead of juggling two `Option`s.
|
/// instead of juggling two `Option`s.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
pub enum ScriptOwner {
|
pub enum ScriptOwner {
|
||||||
App(AppId),
|
App(AppId),
|
||||||
Group(GroupId),
|
Group(GroupId),
|
||||||
|
|||||||
Reference in New Issue
Block a user