feat(vars): VarsService + vars:: SDK + config capabilities
Scripts can now read group-inherited config via vars::get(key) / vars::all(). - shared: VarsService trait (read-only get/all) + NoopVarsService; added as the 14th field on the Services bundle. - manager-core: VarsServiceImpl resolves the calling app's config via config_resolver (derives app_id from cx.app_id; AppVarsRead-gated for authed principals, script-as-gate for anon). - executor-core: vars:: Rhai bridge (get/all), registered in the SDK. - authz: AppVarsRead/Write, GroupVarsRead/Write, GroupSecretsRead/Write capabilities (group caps resolve via the Phase-2 ancestor walk; the GroupSecretsRead human-read gate is group_admin-only, distinct from an app's runtime injection). - wired VarsServiceImpl into the picloud binary; updated the executor-core test Services::new call sites. 386 manager-core lib tests green; clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
57
crates/executor-core/src/sdk/vars.rs
Normal file
57
crates/executor-core/src/sdk/vars.rs
Normal file
@@ -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<SdkCallCx>) {
|
||||
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<Dynamic, Box<EvalAltResult>> {
|
||||
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<Map, Box<EvalAltResult>> {
|
||||
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());
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -104,6 +104,7 @@ fn services_with(modules: Arc<dyn ModuleSource>) -> Services {
|
||||
Arc::new(picloud_shared::NoopUsersService),
|
||||
Arc::new(picloud_shared::NoopQueueService),
|
||||
Arc::new(picloud_shared::NoopInvokeService),
|
||||
Arc::new(picloud_shared::NoopVarsService),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -235,6 +235,7 @@ fn make_engine() -> Arc<Engine> {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ fn engine_with(rec: Arc<RecordingEmail>) -> Arc<Engine> {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -172,6 +172,7 @@ fn make_engine() -> Arc<Engine> {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ fn engine_with(http: Arc<dyn HttpService>) -> Arc<Engine> {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ fn build_engine(svc: Arc<FakeInvokeService>) -> Arc<Engine> {
|
||||
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));
|
||||
|
||||
@@ -114,6 +114,7 @@ fn make_engine() -> Arc<Engine> {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ fn make_engine(svc: Arc<RecordingPubsub>) -> Arc<Engine> {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ fn make_engine(svc: Arc<RecordingQueue>) -> Arc<Engine> {
|
||||
Arc::new(picloud_shared::NoopUsersService),
|
||||
svc,
|
||||
Arc::new(picloud_shared::NoopInvokeService),
|
||||
Arc::new(picloud_shared::NoopVarsService),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ fn build_engine() -> Arc<Engine> {
|
||||
Arc::new(NoopUsersService),
|
||||
Arc::new(NoopQueueService),
|
||||
Arc::new(NoopInvokeService),
|
||||
Arc::new(picloud_shared::NoopVarsService),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@ fn make_engine() -> Arc<Engine> {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -99,6 +99,7 @@ fn make_engine() -> Arc<Engine> {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
63
crates/manager-core/src/vars_service.rs
Normal file
63
crates/manager-core/src/vars_service.rs
Normal file
@@ -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<dyn AuthzRepo>,
|
||||
}
|
||||
|
||||
impl VarsServiceImpl {
|
||||
#[must_use]
|
||||
pub fn new(pool: PgPool, authz: Arc<dyn AuthzRepo>) -> 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<BTreeMap<String, Value>, 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<Option<Value>, VarsError> {
|
||||
self.check_read(cx).await?;
|
||||
Ok(self.resolved(cx).await?.remove(key))
|
||||
}
|
||||
|
||||
async fn all(&self, cx: &SdkCallCx) -> Result<BTreeMap<String, Value>, VarsError> {
|
||||
self.check_read(cx).await?;
|
||||
self.resolved(cx).await
|
||||
}
|
||||
}
|
||||
@@ -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<dyn picloud_shared::VarsService> =
|
||||
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).
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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<dyn InvokeService>,
|
||||
|
||||
/// 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<dyn VarsService>,
|
||||
}
|
||||
|
||||
impl Services {
|
||||
@@ -129,6 +136,7 @@ impl Services {
|
||||
users: Arc<dyn UsersService>,
|
||||
queue: Arc<dyn QueueService>,
|
||||
invoke: Arc<dyn InvokeService>,
|
||||
vars: Arc<dyn VarsService>,
|
||||
) -> 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
48
crates/shared/src/vars.rs
Normal file
48
crates/shared/src/vars.rs
Normal file
@@ -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<Option<Value>, VarsError>;
|
||||
|
||||
/// The app's fully-resolved config map (every inherited + own key).
|
||||
async fn all(&self, cx: &SdkCallCx) -> Result<BTreeMap<String, Value>, 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<Option<Value>, VarsError> {
|
||||
Err(VarsError::Backend("vars is not wired in".into()))
|
||||
}
|
||||
async fn all(&self, _cx: &SdkCallCx) -> Result<BTreeMap<String, Value>, VarsError> {
|
||||
Err(VarsError::Backend("vars is not wired in".into()))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user