diff --git a/crates/executor-core/src/sdk/mod.rs b/crates/executor-core/src/sdk/mod.rs index 378efea..649fb56 100644 --- a/crates/executor-core/src/sdk/mod.rs +++ b/crates/executor-core/src/sdk/mod.rs @@ -26,6 +26,7 @@ pub mod retry; pub mod secrets; pub mod stdlib; pub mod users; +pub mod vars; pub use bridge::{dynamic_to_json, json_to_dynamic}; pub use cx::SdkCallCx; @@ -62,6 +63,7 @@ pub fn register_all( queue::register(engine, services, cx.clone()); retry::register(engine, services, cx.clone()); secrets::register(engine, services, cx.clone()); + vars::register(engine, services, cx.clone()); email::register(engine, services, cx.clone()); users::register(engine, services, cx.clone()); invoke::register(engine, services, cx, limits, self_engine); diff --git a/crates/executor-core/src/sdk/vars.rs b/crates/executor-core/src/sdk/vars.rs new file mode 100644 index 0000000..ca43421 --- /dev/null +++ b/crates/executor-core/src/sdk/vars.rs @@ -0,0 +1,57 @@ +//! `vars::` Rhai bridge — read-only access to the app's resolved, +//! group-inherited config (Phase 3). +//! +//! ```rhai +//! let region = vars::get("region"); // value or () +//! let all = vars::all(); // #{ key: value, ... } +//! ``` +//! +//! Values are inherited down the group tree and env-filtered (§3); the +//! resolution happens server-side in manager-core. Writes go through the +//! admin API, not the SDK. `app_id` is derived from `cx.app_id` in the +//! service — never a script argument — preserving cross-app isolation. + +use std::sync::Arc; + +use picloud_shared::{SdkCallCx, Services}; +use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module}; + +use super::bridge::{block_on, json_to_dynamic}; + +pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc) { + let svc = services.vars.clone(); + let mut module = Module::new(); + + // vars::get(key) — resolved value, or () if no level defines it. + { + let svc = svc.clone(); + let cx = cx.clone(); + module.set_native_fn( + "get", + move |key: &str| -> Result> { + let svc = svc.clone(); + let cx = cx.clone(); + let opt = block_on("vars", async move { svc.get(&cx, key).await })?; + Ok(opt.map_or(Dynamic::UNIT, json_to_dynamic)) + }, + ); + } + + // vars::all() — the fully-resolved config map. + { + let svc = svc.clone(); + let cx = cx.clone(); + module.set_native_fn("all", move || -> Result> { + let svc = svc.clone(); + let cx = cx.clone(); + let resolved = block_on("vars", async move { svc.all(&cx).await })?; + let mut m = Map::new(); + for (k, v) in resolved { + m.insert(k.into(), json_to_dynamic(v)); + } + Ok(m) + }); + } + + engine.register_static_module("vars", module.into()); +} diff --git a/crates/executor-core/tests/module_redaction_logging.rs b/crates/executor-core/tests/module_redaction_logging.rs index 09f5362..9850387 100644 --- a/crates/executor-core/tests/module_redaction_logging.rs +++ b/crates/executor-core/tests/module_redaction_logging.rs @@ -107,6 +107,7 @@ async fn original_backend_error_is_logged_at_error_level() { Arc::new(picloud_shared::NoopUsersService), Arc::new(picloud_shared::NoopQueueService), Arc::new(picloud_shared::NoopInvokeService), + Arc::new(picloud_shared::NoopVarsService), ); let engine = Engine::new(Limits::default(), services); diff --git a/crates/executor-core/tests/modules.rs b/crates/executor-core/tests/modules.rs index 7897918..7a1607f 100644 --- a/crates/executor-core/tests/modules.rs +++ b/crates/executor-core/tests/modules.rs @@ -104,6 +104,7 @@ fn services_with(modules: Arc) -> Services { Arc::new(picloud_shared::NoopUsersService), Arc::new(picloud_shared::NoopQueueService), Arc::new(picloud_shared::NoopInvokeService), + Arc::new(picloud_shared::NoopVarsService), ) } diff --git a/crates/executor-core/tests/sdk_docs.rs b/crates/executor-core/tests/sdk_docs.rs index 01db6e5..9feb03a 100644 --- a/crates/executor-core/tests/sdk_docs.rs +++ b/crates/executor-core/tests/sdk_docs.rs @@ -235,6 +235,7 @@ fn make_engine() -> Arc { Arc::new(picloud_shared::NoopUsersService), Arc::new(picloud_shared::NoopQueueService), Arc::new(picloud_shared::NoopInvokeService), + Arc::new(picloud_shared::NoopVarsService), ); Arc::new(Engine::new(Limits::default(), services)) } diff --git a/crates/executor-core/tests/sdk_email.rs b/crates/executor-core/tests/sdk_email.rs index c562a7f..160441d 100644 --- a/crates/executor-core/tests/sdk_email.rs +++ b/crates/executor-core/tests/sdk_email.rs @@ -44,6 +44,7 @@ fn engine_with(rec: Arc) -> Arc { Arc::new(picloud_shared::NoopUsersService), Arc::new(picloud_shared::NoopQueueService), Arc::new(picloud_shared::NoopInvokeService), + Arc::new(picloud_shared::NoopVarsService), ); Arc::new(Engine::new(Limits::default(), services)) } diff --git a/crates/executor-core/tests/sdk_files.rs b/crates/executor-core/tests/sdk_files.rs index 639bc37..68a29c6 100644 --- a/crates/executor-core/tests/sdk_files.rs +++ b/crates/executor-core/tests/sdk_files.rs @@ -172,6 +172,7 @@ fn make_engine() -> Arc { Arc::new(picloud_shared::NoopUsersService), Arc::new(picloud_shared::NoopQueueService), Arc::new(picloud_shared::NoopInvokeService), + Arc::new(picloud_shared::NoopVarsService), ); Arc::new(Engine::new(Limits::default(), services)) } diff --git a/crates/executor-core/tests/sdk_http.rs b/crates/executor-core/tests/sdk_http.rs index 672bcf4..7c175b8 100644 --- a/crates/executor-core/tests/sdk_http.rs +++ b/crates/executor-core/tests/sdk_http.rs @@ -95,6 +95,7 @@ fn engine_with(http: Arc) -> Arc { Arc::new(picloud_shared::NoopUsersService), Arc::new(picloud_shared::NoopQueueService), Arc::new(picloud_shared::NoopInvokeService), + Arc::new(picloud_shared::NoopVarsService), ); Arc::new(Engine::new(Limits::default(), services)) } diff --git a/crates/executor-core/tests/sdk_invoke.rs b/crates/executor-core/tests/sdk_invoke.rs index b9cc325..67468ce 100644 --- a/crates/executor-core/tests/sdk_invoke.rs +++ b/crates/executor-core/tests/sdk_invoke.rs @@ -90,6 +90,7 @@ fn build_engine(svc: Arc) -> Arc { Arc::new(NoopUsersService), Arc::new(NoopQueueService), svc, + Arc::new(picloud_shared::NoopVarsService), ); let engine = Arc::new(Engine::new(Limits::default(), services)); engine.set_self_weak(Arc::downgrade(&engine)); diff --git a/crates/executor-core/tests/sdk_kv.rs b/crates/executor-core/tests/sdk_kv.rs index c37a2a5..6b3b059 100644 --- a/crates/executor-core/tests/sdk_kv.rs +++ b/crates/executor-core/tests/sdk_kv.rs @@ -114,6 +114,7 @@ fn make_engine() -> Arc { Arc::new(picloud_shared::NoopUsersService), Arc::new(picloud_shared::NoopQueueService), Arc::new(picloud_shared::NoopInvokeService), + Arc::new(picloud_shared::NoopVarsService), ); Arc::new(Engine::new(Limits::default(), services)) } diff --git a/crates/executor-core/tests/sdk_pubsub.rs b/crates/executor-core/tests/sdk_pubsub.rs index cdecc3d..56daabe 100644 --- a/crates/executor-core/tests/sdk_pubsub.rs +++ b/crates/executor-core/tests/sdk_pubsub.rs @@ -52,6 +52,7 @@ fn make_engine(svc: Arc) -> Arc { Arc::new(picloud_shared::NoopUsersService), Arc::new(picloud_shared::NoopQueueService), Arc::new(picloud_shared::NoopInvokeService), + Arc::new(picloud_shared::NoopVarsService), ); Arc::new(Engine::new(Limits::default(), services)) } diff --git a/crates/executor-core/tests/sdk_queue.rs b/crates/executor-core/tests/sdk_queue.rs index c776b68..aaea046 100644 --- a/crates/executor-core/tests/sdk_queue.rs +++ b/crates/executor-core/tests/sdk_queue.rs @@ -66,6 +66,7 @@ fn make_engine(svc: Arc) -> Arc { Arc::new(picloud_shared::NoopUsersService), svc, Arc::new(picloud_shared::NoopInvokeService), + Arc::new(picloud_shared::NoopVarsService), ); Arc::new(Engine::new(Limits::default(), services)) } diff --git a/crates/executor-core/tests/sdk_retry.rs b/crates/executor-core/tests/sdk_retry.rs index 11ac00f..116698b 100644 --- a/crates/executor-core/tests/sdk_retry.rs +++ b/crates/executor-core/tests/sdk_retry.rs @@ -30,6 +30,7 @@ fn build_engine() -> Arc { Arc::new(NoopUsersService), Arc::new(NoopQueueService), Arc::new(NoopInvokeService), + Arc::new(picloud_shared::NoopVarsService), ); Arc::new(Engine::new(Limits::default(), services)) } diff --git a/crates/executor-core/tests/sdk_secrets.rs b/crates/executor-core/tests/sdk_secrets.rs index 2836134..4a5815b 100644 --- a/crates/executor-core/tests/sdk_secrets.rs +++ b/crates/executor-core/tests/sdk_secrets.rs @@ -105,6 +105,7 @@ fn make_engine() -> Arc { Arc::new(picloud_shared::NoopUsersService), Arc::new(picloud_shared::NoopQueueService), Arc::new(picloud_shared::NoopInvokeService), + Arc::new(picloud_shared::NoopVarsService), ); Arc::new(Engine::new(Limits::default(), services)) } diff --git a/crates/executor-core/tests/sdk_subscriber_token.rs b/crates/executor-core/tests/sdk_subscriber_token.rs index 702313a..50309d1 100644 --- a/crates/executor-core/tests/sdk_subscriber_token.rs +++ b/crates/executor-core/tests/sdk_subscriber_token.rs @@ -99,6 +99,7 @@ fn make_engine() -> Arc { Arc::new(picloud_shared::NoopUsersService), Arc::new(picloud_shared::NoopQueueService), Arc::new(picloud_shared::NoopInvokeService), + Arc::new(picloud_shared::NoopVarsService), ); Arc::new(Engine::new(Limits::default(), services)) } diff --git a/crates/manager-core/src/authz.rs b/crates/manager-core/src/authz.rs index 9f1cf56..3729fa2 100644 --- a/crates/manager-core/src/authz.rs +++ b/crates/manager-core/src/authz.rs @@ -116,6 +116,25 @@ pub enum Capability { /// Write (set/delete) a secret in this app's secrets store (v1.1.7). /// Granted to `editor`+, maps to `script:write` on API keys. AppSecretsWrite(AppId), + /// Read this app's resolved config vars (Phase 3). Same trust shape as + /// secrets-read — granted to `viewer`+, maps to `script:read`. + AppVarsRead(AppId), + /// Write (set/delete) an app-owned config var (Phase 3). Granted to + /// `editor`+, maps to `script:write`. + AppVarsWrite(AppId), + /// Read a group's config vars (Phase 3). Resolved via the group + /// ancestor walk; viewer+ on the group. + GroupVarsRead(GroupId), + /// Write (set/delete) a group-owned config var (Phase 3). editor+ on + /// the group. + GroupVarsWrite(GroupId), + /// Read a group-owned secret's VALUE (Phase 3, the human-read gate). + /// group_admin on the owning group — distinct from runtime injection, + /// which an inheriting app does without this check. + GroupSecretsRead(GroupId), + /// Write (set/delete) a group-owned secret (Phase 3). editor+ on the + /// group. + GroupSecretsWrite(GroupId), /// Send an outbound email from a script in this app (v1.1.7). Maps /// to `script:write` on API keys (sending mail is an outbound /// side-effect like an HTTP request). Granted to `editor`+. @@ -176,7 +195,11 @@ impl Capability { // app) is denied group management at the binding layer. | Self::GroupRead(_) | Self::GroupWrite(_) - | Self::GroupAdmin(_) => None, + | Self::GroupAdmin(_) + | Self::GroupVarsRead(_) + | Self::GroupVarsWrite(_) + | Self::GroupSecretsRead(_) + | Self::GroupSecretsWrite(_) => None, Self::AppRead(id) | Self::AppWriteScript(id) | Self::AppWriteRoute(id) @@ -194,6 +217,8 @@ impl Capability { | Self::AppQueueEnqueue(id) | Self::AppSecretsRead(id) | Self::AppSecretsWrite(id) + | Self::AppVarsRead(id) + | Self::AppVarsWrite(id) | Self::AppEmailSend(id) | Self::AppManageTriggers(id) | Self::AppDeadLetterManage(id) @@ -223,7 +248,9 @@ impl Capability { | Self::AppFilesRead(_) | Self::AppSecretsRead(_) | Self::AppUsersRead(_) - | Self::GroupRead(_) => Scope::ScriptRead, + | Self::AppVarsRead(_) + | Self::GroupRead(_) + | Self::GroupVarsRead(_) => Scope::ScriptRead, Self::AppWriteScript(_) | Self::AppKvWrite(_) | Self::AppDocsWrite(_) @@ -235,6 +262,7 @@ impl Capability { | Self::AppEmailSend(_) | Self::AppUsersWrite(_) | Self::AppUsersAdmin(_) + | Self::AppVarsWrite(_) | Self::AppInvoke(_) => Scope::ScriptWrite, Self::AppWriteRoute(_) => Scope::RouteWrite, Self::AppManageDomains(_) => Scope::DomainManage, @@ -243,7 +271,10 @@ impl Capability { | Self::AppDeadLetterManage(_) | Self::AppTopicManage(_) | Self::GroupWrite(_) - | Self::GroupAdmin(_) => Scope::AppAdmin, + | Self::GroupAdmin(_) + | Self::GroupVarsWrite(_) + | Self::GroupSecretsRead(_) + | Self::GroupSecretsWrite(_) => Scope::AppAdmin, Self::AppLogRead(_) => Scope::LogRead, } } @@ -412,7 +443,13 @@ async fn role_grants( // walk (a group_admin on an ancestor is implicitly admin of // the descendant group). Routed before member_grants because // group caps carry no app_id. - Capability::GroupRead(g) | Capability::GroupWrite(g) | Capability::GroupAdmin(g) => { + Capability::GroupRead(g) + | Capability::GroupWrite(g) + | Capability::GroupAdmin(g) + | Capability::GroupVarsRead(g) + | Capability::GroupVarsWrite(g) + | Capability::GroupSecretsRead(g) + | Capability::GroupSecretsWrite(g) => { group_member_grants(repo, principal.user_id, cap, g).await } // Creating a root-level group is an instance act — members @@ -472,9 +509,19 @@ async fn group_member_grants( /// viewer→read, editor→write, group_admin(=AppAdmin)→admin. const fn group_role_satisfies(role: AppRole, cap: Capability) -> bool { match cap { - Capability::GroupRead(_) => true, // any role can read - Capability::GroupWrite(_) => matches!(role, AppRole::Editor | AppRole::AppAdmin), - Capability::GroupAdmin(_) => matches!(role, AppRole::AppAdmin), + // viewer+ reads group metadata and config vars. + Capability::GroupRead(_) | Capability::GroupVarsRead(_) => true, + // editor+ writes config vars/secrets. + Capability::GroupWrite(_) + | Capability::GroupVarsWrite(_) + | Capability::GroupSecretsWrite(_) => { + matches!(role, AppRole::Editor | AppRole::AppAdmin) + } + // group_admin manages the group + reads secret VALUES (the + // human-read gate, distinct from an app's runtime injection). + Capability::GroupAdmin(_) | Capability::GroupSecretsRead(_) => { + matches!(role, AppRole::AppAdmin) + } _ => false, } } @@ -493,6 +540,7 @@ const fn role_satisfies(role: AppRole, cap: Capability) -> bool { | Capability::AppFilesRead(_) | Capability::AppSecretsRead(_) | Capability::AppUsersRead(_) + | Capability::AppVarsRead(_) ); let in_editor = in_viewer || matches!( @@ -508,6 +556,7 @@ const fn role_satisfies(role: AppRole, cap: Capability) -> bool { | Capability::AppSecretsWrite(_) | Capability::AppEmailSend(_) | Capability::AppUsersWrite(_) + | Capability::AppVarsWrite(_) | Capability::AppInvoke(_) ); let in_app_admin = in_editor diff --git a/crates/manager-core/src/lib.rs b/crates/manager-core/src/lib.rs index 176870d..0bd3125 100644 --- a/crates/manager-core/src/lib.rs +++ b/crates/manager-core/src/lib.rs @@ -85,6 +85,7 @@ pub mod trigger_repo; pub mod triggers_api; pub mod users_admin_api; pub mod users_service; +pub mod vars_service; pub use abandoned_repo::{ AbandonedRepo, AbandonedRepoError, NewAbandonedExecution, PostgresAbandonedRepo, @@ -220,3 +221,4 @@ pub use trigger_repo::{ pub use triggers_api::{triggers_router, TriggersApiError, TriggersState}; pub use users_admin_api::{app_users_router, AppUsersApiError, AppUsersState}; pub use users_service::{UsersServiceConfig, UsersServiceImpl}; +pub use vars_service::VarsServiceImpl; diff --git a/crates/manager-core/src/vars_service.rs b/crates/manager-core/src/vars_service.rs new file mode 100644 index 0000000..3a2e524 --- /dev/null +++ b/crates/manager-core/src/vars_service.rs @@ -0,0 +1,63 @@ +//! `VarsService` — the runtime read path for group-inherited config. +//! +//! Resolves the calling app's config (own rows + inherited group rows, +//! env-filtered, proximity-first; see `config_resolver`) and exposes it to +//! scripts as `vars::get(key)` / `vars::all()`. Read-only from scripts; +//! writes go through the admin API. Like every SDK service, it derives the +//! app from `cx.app_id` — never a script argument — so cross-app isolation +//! holds. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use async_trait::async_trait; +use picloud_shared::{SdkCallCx, VarsError, VarsService}; +use serde_json::Value; +use sqlx::PgPool; + +use crate::authz::{self, AuthzRepo, Capability}; +use crate::config_resolver::{fetch_var_candidates, resolve}; + +pub struct VarsServiceImpl { + pool: PgPool, + authz: Arc, +} + +impl VarsServiceImpl { + #[must_use] + pub fn new(pool: PgPool, authz: Arc) -> Self { + Self { pool, authz } + } + + /// Authed principals need `AppVarsRead`; anonymous public-HTTP scripts + /// (`principal: None`) read freely under script-as-gate semantics. + async fn check_read(&self, cx: &SdkCallCx) -> Result<(), VarsError> { + if let Some(ref principal) = cx.principal { + authz::require(&*self.authz, principal, Capability::AppVarsRead(cx.app_id)) + .await + .map_err(|_| VarsError::Forbidden)?; + } + Ok(()) + } + + async fn resolved(&self, cx: &SdkCallCx) -> Result, VarsError> { + let candidates = fetch_var_candidates(&self.pool, cx.app_id) + .await + .map_err(|e| VarsError::Backend(e.to_string()))?; + let (values, _provenance) = resolve(candidates); + Ok(values) + } +} + +#[async_trait] +impl VarsService for VarsServiceImpl { + async fn get(&self, cx: &SdkCallCx, key: &str) -> Result, VarsError> { + self.check_read(cx).await?; + Ok(self.resolved(cx).await?.remove(key)) + } + + async fn all(&self, cx: &SdkCallCx) -> Result, VarsError> { + self.check_read(cx).await?; + self.resolved(cx).await + } +} diff --git a/crates/picloud/src/lib.rs b/crates/picloud/src/lib.rs index 40d4850..203179b 100644 --- a/crates/picloud/src/lib.rs +++ b/crates/picloud/src/lib.rs @@ -33,7 +33,7 @@ use picloud_manager_core::{ PrincipalResolver, PubsubServiceImpl, RealtimeAuthorityImpl, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling, ScriptRepository, SecretsConfig, SecretsServiceImpl, SecretsState, SubscriberTokenConfig, TopicRepo, TopicsState, TriggerConfig, TriggerRepo, - TriggersState, UsersServiceConfig, UsersServiceImpl, + TriggersState, UsersServiceConfig, UsersServiceImpl, VarsServiceImpl, }; use picloud_orchestrator_core::realtime::DEFAULT_GC_INTERVAL_SECS; use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable}; @@ -322,6 +322,8 @@ pub async fn build_app( // under script-as-gate semantics. .with_authz(authz.clone()), ); + let vars: Arc = + Arc::new(VarsServiceImpl::new(pool.clone(), authz.clone())); let services = Services::new( kv, docs, @@ -336,6 +338,7 @@ pub async fn build_app( users.clone(), queue, invoke, + vars, ); // v1.1.9: keep the invoke depth bound aligned with the dispatcher's // trigger-depth bound (same counter under the hood). diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index 94a6992..53eca9b 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -38,6 +38,7 @@ pub mod subscriber_token; pub mod trigger_event; pub mod users; pub mod validator; +pub mod vars; pub mod version; /// serde `default` for `enabled`-style boolean fields that should default to @@ -101,4 +102,5 @@ pub use users::{ UsersService, }; pub use validator::{ScriptValidator, ValidatedScript, ValidationError}; +pub use vars::{NoopVarsService, VarsError, VarsService}; pub use version::{API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION}; diff --git a/crates/shared/src/services.rs b/crates/shared/src/services.rs index 81971fb..bf527ee 100644 --- a/crates/shared/src/services.rs +++ b/crates/shared/src/services.rs @@ -24,7 +24,8 @@ use crate::{ KvService, ModuleSource, NoopDeadLetterService, NoopDocsService, NoopEmailService, NoopEventEmitter, NoopFilesService, NoopHttpService, NoopInvokeService, NoopKvService, NoopModuleSource, NoopPubsubService, NoopQueueService, NoopSecretsService, NoopUsersService, - PubsubService, QueueService, SecretsService, ServiceEventEmitter, UsersService, + NoopVarsService, PubsubService, QueueService, SecretsService, ServiceEventEmitter, + UsersService, VarsService, }; /// SDK service bundle. See module docs for the lifecycle and the v1.1.x @@ -107,6 +108,12 @@ pub struct Services { /// and `invoke_async()` (fire-and-forget through the outbox). /// Cross-app invokes are rejected at the service entry point. pub invoke: Arc, + + /// Group-inherited, env-scoped config (Phase 3). Scripts get + /// read-only `vars::{get,all}`; values resolve down the group tree + /// (§3). Backed by `config_resolver` over Postgres in the picloud + /// binary; `NoopVarsService` in tests that don't read config. + pub vars: Arc, } impl Services { @@ -129,6 +136,7 @@ impl Services { users: Arc, queue: Arc, invoke: Arc, + vars: Arc, ) -> Self { Self { kv, @@ -144,6 +152,7 @@ impl Services { users, queue, invoke, + vars, } } @@ -168,6 +177,7 @@ impl Services { Arc::new(NoopUsersService), Arc::new(NoopQueueService), Arc::new(NoopInvokeService), + Arc::new(NoopVarsService), ) } } diff --git a/crates/shared/src/vars.rs b/crates/shared/src/vars.rs new file mode 100644 index 0000000..96af8f4 --- /dev/null +++ b/crates/shared/src/vars.rs @@ -0,0 +1,48 @@ +//! `vars::*` — read-only access from scripts to the app's resolved +//! configuration (Phase 3). Values are inherited down the group tree and +//! env-filtered per the §3 resolution rule; the resolution happens in +//! manager-core (`config_resolver`). Writes go through the admin API, not +//! the SDK — scripts only read. + +use std::collections::BTreeMap; + +use async_trait::async_trait; +use serde_json::Value; + +use crate::SdkCallCx; + +#[derive(Debug, thiserror::Error)] +pub enum VarsError { + /// Caller principal lacked `AppVarsRead`. Only raised when + /// `cx.principal.is_some()` (public-HTTP scripts skip the check — + /// script-as-gate). + #[error("forbidden")] + Forbidden, + /// Postgres unavailable, malformed row, etc. + #[error("vars backend error: {0}")] + Backend(String), +} + +#[async_trait] +pub trait VarsService: Send + Sync { + /// Resolve a single config key for the calling app's environment. + /// `None` if no level defines it (or a tombstone suppresses it). + async fn get(&self, cx: &SdkCallCx, key: &str) -> Result, VarsError>; + + /// The app's fully-resolved config map (every inherited + own key). + async fn all(&self, cx: &SdkCallCx) -> Result, VarsError>; +} + +/// All-noop fallback for engines that don't wire vars (tests). Every call +/// surfaces an explicit error rather than silently returning empty. +pub struct NoopVarsService; + +#[async_trait] +impl VarsService for NoopVarsService { + async fn get(&self, _cx: &SdkCallCx, _key: &str) -> Result, VarsError> { + Err(VarsError::Backend("vars is not wired in".into())) + } + async fn all(&self, _cx: &SdkCallCx) -> Result, VarsError> { + Err(VarsError::Backend("vars is not wired in".into())) + } +}