feat(modules): GroupKvService + kv::shared SDK handle (§11.6 C3)

The runtime for shared group collections. A script reaches a shared store via
the explicit kv::shared("name") handle (distinct GroupKvHandle Rhai type, so
private-vs-shared scope is a deliberate choice). The service resolves the
owning group from cx.app_id's ancestor chain — the script never names a group,
and that walk is the isolation boundary (a foreign app's chain never contains
the owning group → CollectionNotShared).

- shared: GroupKvService trait + GroupKvError + NoopGroupKvService; threaded
  into the Services bundle as a field defaulted to noop, opted into via a new
  with_group_kv() (keeps the ~14 Services::new call sites unchanged).
- manager-core: GroupKvServiceImpl with the reads-open/writes-authed split —
  reads use script_gate (anonymous public scripts skip), writes use the new
  script_gate_require_principal (anonymous fails closed). Owner resolution
  behind a GroupCollectionResolver trait (Postgres impl + injectable fake);
  unit tests cover off-chain CollectionNotShared, anonymous-read-OK/
  anonymous-write-Forbidden, authed-no-role-Forbidden.
- executor-core: GroupKvHandle + kv::shared constructor + get/set/has/delete/
  list (Rhai dispatches by receiver type, reusing the block_on bridge).
- picloud: wire PostgresGroupKvRepo + resolver + GroupKvServiceImpl.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-29 21:44:53 +02:00
parent d1de43e919
commit d9766bcbc7
8 changed files with 811 additions and 21 deletions

View File

@@ -0,0 +1,141 @@
//! `GroupKvService` — the §11.6 shared group-collection KV contract.
//!
//! The cross-app-sharing counterpart to [`crate::KvService`]. A script reaches
//! it via the explicit `kv::shared("name")` handle (distinct from the
//! app-private `kv::collection("name")`). Unlike `KvService`, the *owner* of
//! the data is not `cx.app_id`: the impl resolves the **owning group** by
//! walking the calling app's ancestor chain for the nearest group that declares
//! the collection group-shared. That ancestry walk is the isolation boundary —
//! a foreign app's chain never contains the owning group, so the name does not
//! resolve. The trait surface therefore takes **no** group id (same discipline
//! as `KvService` omitting `app_id`): owner derivation stays inside the impl.
//!
//! Trust model (§11.6): **reads are open** to any script in the subtree
//! (anonymous public HTTP included — the operator opted in by declaring the
//! collection); **writes require an authenticated principal** with editor+ on
//! the owning group.
use async_trait::async_trait;
use thiserror::Error;
use crate::{KvListPage, SdkCallCx};
#[async_trait]
pub trait GroupKvService: Send + Sync {
async fn get(
&self,
cx: &SdkCallCx,
collection: &str,
key: &str,
) -> Result<Option<serde_json::Value>, GroupKvError>;
async fn set(
&self,
cx: &SdkCallCx,
collection: &str,
key: &str,
value: serde_json::Value,
) -> Result<(), GroupKvError>;
async fn delete(
&self,
cx: &SdkCallCx,
collection: &str,
key: &str,
) -> Result<bool, GroupKvError>;
async fn has(&self, cx: &SdkCallCx, collection: &str, key: &str) -> Result<bool, GroupKvError>;
async fn list(
&self,
cx: &SdkCallCx,
collection: &str,
cursor: Option<&str>,
limit: u32,
) -> Result<KvListPage, GroupKvError>;
}
/// Stub for test bundles that don't exercise shared collections. Every call
/// errors so accidental use surfaces clearly.
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopGroupKvService;
#[async_trait]
impl GroupKvService for NoopGroupKvService {
async fn get(
&self,
_cx: &SdkCallCx,
_collection: &str,
_key: &str,
) -> Result<Option<serde_json::Value>, GroupKvError> {
Err(GroupKvError::Backend("group kv is not wired in".into()))
}
async fn set(
&self,
_cx: &SdkCallCx,
_collection: &str,
_key: &str,
_value: serde_json::Value,
) -> Result<(), GroupKvError> {
Err(GroupKvError::Backend("group kv is not wired in".into()))
}
async fn delete(
&self,
_cx: &SdkCallCx,
_collection: &str,
_key: &str,
) -> Result<bool, GroupKvError> {
Err(GroupKvError::Backend("group kv is not wired in".into()))
}
async fn has(
&self,
_cx: &SdkCallCx,
_collection: &str,
_key: &str,
) -> Result<bool, GroupKvError> {
Err(GroupKvError::Backend("group kv is not wired in".into()))
}
async fn list(
&self,
_cx: &SdkCallCx,
_collection: &str,
_cursor: Option<&str>,
_limit: u32,
) -> Result<KvListPage, GroupKvError> {
Err(GroupKvError::Backend("group kv is not wired in".into()))
}
}
/// Failure modes surfaced to the Rhai bridge.
#[derive(Debug, Error)]
pub enum GroupKvError {
/// Empty collection name; rejected at the SDK boundary.
#[error("collection name must not be empty")]
InvalidCollection,
/// No group on the calling app's ancestor chain declares this collection
/// group-shared — the structural "not shared with you" boundary. Also the
/// outcome for a foreign app naming another subtree's collection.
#[error("collection {0:?} is not a shared group collection for this app")]
CollectionNotShared(String),
/// Caller lacked the required capability. For reads this is only raised
/// when `cx.principal.is_some()`; for WRITES it is also raised when the
/// principal is absent (writes fail closed — shared mutation always needs
/// an authenticated editor+ on the owning group).
#[error("forbidden")]
Forbidden,
/// JSON-encoded value exceeded the per-value `max_value_bytes` cap
/// (`PICLOUD_KV_MAX_VALUE_BYTES`).
#[error("group kv: value too large ({actual} bytes; limit {limit})")]
ValueTooLarge { limit: usize, actual: usize },
/// Anything else — Postgres unavailable, serialization failure, etc.
#[error("group kv backend error: {0}")]
Backend(String),
}

View File

@@ -16,6 +16,7 @@ pub mod exec_summary;
pub mod execution_log;
pub mod files;
pub mod group;
pub mod group_kv;
pub mod http;
pub mod ids;
pub mod inbox;
@@ -65,6 +66,7 @@ pub use files::{
SAFE_RENDER_FALLBACK,
};
pub use group::Group;
pub use group_kv::{GroupKvError, GroupKvService, NoopGroupKvService};
pub use http::{HttpError, HttpRequest, HttpResponse, HttpService, NoopHttpService};
pub use ids::{
AdminUserId, ApiKeyId, AppId, AppUserId, ExecutionId, GroupId, InvitationId, QueueMessageId,

View File

@@ -20,12 +20,12 @@
use std::sync::Arc;
use crate::{
DeadLetterService, DocsService, EmailService, FilesService, HttpService, InvokeService,
KvService, ModuleSource, NoopDeadLetterService, NoopDocsService, NoopEmailService,
NoopEventEmitter, NoopFilesService, NoopHttpService, NoopInvokeService, NoopKvService,
NoopModuleSource, NoopPubsubService, NoopQueueService, NoopSecretsService, NoopUsersService,
NoopVarsService, PubsubService, QueueService, SecretsService, ServiceEventEmitter,
UsersService, VarsService,
DeadLetterService, DocsService, EmailService, FilesService, GroupKvService, HttpService,
InvokeService, KvService, ModuleSource, NoopDeadLetterService, NoopDocsService,
NoopEmailService, NoopEventEmitter, NoopFilesService, NoopGroupKvService, NoopHttpService,
NoopInvokeService, NoopKvService, NoopModuleSource, NoopPubsubService, NoopQueueService,
NoopSecretsService, NoopUsersService, NoopVarsService, PubsubService, QueueService,
SecretsService, ServiceEventEmitter, UsersService, VarsService,
};
/// SDK service bundle. See module docs for the lifecycle and the v1.1.x
@@ -114,6 +114,14 @@ pub struct Services {
/// (§3). Backed by `config_resolver` over Postgres in the picloud
/// binary; `NoopVarsService` in tests that don't read config.
pub vars: Arc<dyn VarsService>,
/// Shared cross-app group collections (§11.6). Scripts get
/// `kv::shared(name)` — read/write KV scoped to the nearest ancestor
/// group that declares the collection shared. Backed by Postgres in the
/// picloud binary (wired via [`Services::with_group_kv`]); defaults to
/// `NoopGroupKvService` so test bundles that don't exercise sharing build
/// unchanged.
pub group_kv: Arc<dyn GroupKvService>,
}
impl Services {
@@ -153,9 +161,21 @@ impl Services {
queue,
invoke,
vars,
// §11.6 group collections default to noop; the picloud binary opts
// in via `with_group_kv`. Keeps the ~14 `Services::new` call sites
// (mostly tests) unchanged — a field addition, not a param.
group_kv: Arc::new(NoopGroupKvService),
}
}
/// Set the §11.6 shared group-collection service (the picloud binary wires
/// the Postgres-backed impl; tests leave the noop default).
#[must_use]
pub fn with_group_kv(mut self, group_kv: Arc<dyn GroupKvService>) -> Self {
self.group_kv = group_kv;
self
}
/// All-noop bundle for tests that build an `Engine` but don't
/// exercise the stateful services. Returns the same shape as
/// `Services::new` so callers can't accidentally rely on a stub