Compare commits
26 Commits
feat/hiera
...
e885ed5412
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e885ed5412 | ||
|
|
9c6d690792 | ||
|
|
17221a2683 | ||
|
|
e7c0485dbf | ||
|
|
3479afc56b | ||
|
|
779d906d75 | ||
|
|
f552238586 | ||
|
|
a00454e5de | ||
|
|
5efb068b9f | ||
|
|
78a468de83 | ||
|
|
61352c7e5e | ||
|
|
5d1d4b3ff6 | ||
|
|
af525e71bd | ||
|
|
0973344515 | ||
|
|
b79d8ef47d | ||
|
|
d9766bcbc7 | ||
|
|
d1de43e919 | ||
|
|
f1d5f5c34e | ||
|
|
e53e2f583d | ||
|
|
529725ebb6 | ||
|
|
a049397bb0 | ||
|
|
82b4579317 | ||
|
|
e62e073970 | ||
|
|
8f966783fe | ||
|
|
8b68a7d7e8 | ||
|
|
4e25a142bd |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -26,8 +26,11 @@ docker-compose.override.yml
|
||||
config.local.toml
|
||||
/data
|
||||
# Files-root blob storage created when integration tests run build_app
|
||||
# from the picloud crate dir (PICLOUD_FILES_ROOT default ./data).
|
||||
# from a crate dir (PICLOUD_FILES_ROOT default ./data). The CLI journey
|
||||
# harness spawns the server from the picloud-cli dir, so it lands there too
|
||||
# (the §11.6 group-files journey is the first CLI test to write blobs).
|
||||
/crates/picloud/data
|
||||
/crates/picloud-cli/data
|
||||
/postgres-data
|
||||
|
||||
# Dashboard
|
||||
|
||||
@@ -377,17 +377,40 @@ impl ModuleResolver for PicloudModuleResolver {
|
||||
let origin = importer_source
|
||||
.and_then(decode_origin)
|
||||
.unwrap_or(self.default_origin);
|
||||
let lookup_result: Result<Option<picloud_shared::ModuleScript>, ModuleSourceError> =
|
||||
tokio::task::block_in_place(|| handle.block_on(self.source.resolve(origin, path)));
|
||||
// §5.5 policy resolution: the source decides lexical (seal to `origin`)
|
||||
// vs dynamic (an extension point resolves against the inheriting app,
|
||||
// `cx.app_id`). The result is already the concrete module to bind, or a
|
||||
// terminal NoProvider/NotFound.
|
||||
let lookup_result: Result<picloud_shared::ModuleResolution, ModuleSourceError> =
|
||||
tokio::task::block_in_place(|| {
|
||||
handle.block_on(self.source.resolve_policy(origin, self.cx.app_id, path))
|
||||
});
|
||||
|
||||
let module_row = match lookup_result {
|
||||
Ok(Some(m)) => m,
|
||||
Ok(None) => {
|
||||
Ok(picloud_shared::ModuleResolution::Module(m)) => m,
|
||||
Ok(picloud_shared::ModuleResolution::NotFound) => {
|
||||
return Err(Box::new(EvalAltResult::ErrorModuleNotFound(
|
||||
path.to_string(),
|
||||
pos,
|
||||
)));
|
||||
}
|
||||
Ok(picloud_shared::ModuleResolution::NoProvider) => {
|
||||
// §5.5: an extension point with no provider for this app is a
|
||||
// hard failure (the app must supply the module or a default
|
||||
// body must exist up-chain). Distinct, actionable message.
|
||||
return Err(Box::new(EvalAltResult::ErrorInModule(
|
||||
path.to_string(),
|
||||
Box::new(EvalAltResult::ErrorRuntime(
|
||||
format!(
|
||||
"extension point {path:?} has no provider for this app — \
|
||||
define a module named {path:?} or a default body up-chain"
|
||||
)
|
||||
.into(),
|
||||
pos,
|
||||
)),
|
||||
pos,
|
||||
)));
|
||||
}
|
||||
Err(e) => {
|
||||
// v1.1.4 §10a: redact the backend error before it
|
||||
// reaches a script. In public-HTTP context (principal:
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use picloud_shared::{DocId, DocRow, DocsService, SdkCallCx, Services};
|
||||
use picloud_shared::{DocId, DocRow, DocsService, GroupDocsService, SdkCallCx, Services};
|
||||
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -38,8 +38,20 @@ pub struct DocsHandle {
|
||||
cx: Arc<SdkCallCx>,
|
||||
}
|
||||
|
||||
/// §11.6 shared-collection handle, returned by `docs::shared_collection(name)`.
|
||||
/// A distinct Rhai type from `DocsHandle` so a script's choice of
|
||||
/// private-vs-shared scope is explicit. Routes through `GroupDocsService`, which
|
||||
/// resolves the owning group from `cx.app_id` (the script never names a group).
|
||||
#[derive(Clone)]
|
||||
pub struct GroupDocsHandle {
|
||||
collection: String,
|
||||
service: Arc<dyn GroupDocsService>,
|
||||
cx: Arc<SdkCallCx>,
|
||||
}
|
||||
|
||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||
let docs_service = services.docs.clone();
|
||||
let group_docs_service = services.group_docs.clone();
|
||||
|
||||
let mut module = Module::new();
|
||||
{
|
||||
@@ -59,6 +71,23 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
||||
},
|
||||
);
|
||||
}
|
||||
{
|
||||
let group_docs_service = group_docs_service.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"shared_collection",
|
||||
move |name: &str| -> Result<GroupDocsHandle, Box<EvalAltResult>> {
|
||||
if name.is_empty() {
|
||||
return Err("docs::shared_collection name must not be empty".into());
|
||||
}
|
||||
Ok(GroupDocsHandle {
|
||||
collection: name.to_string(),
|
||||
service: group_docs_service.clone(),
|
||||
cx: cx.clone(),
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
engine.register_static_module("docs", module.into());
|
||||
|
||||
engine.register_type_with_name::<DocsHandle>("DocsHandle");
|
||||
@@ -70,6 +99,17 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
||||
register_update(engine);
|
||||
register_delete(engine);
|
||||
register_list(engine);
|
||||
|
||||
// Same method names on GroupDocsHandle — Rhai dispatches by receiver type.
|
||||
engine.register_type_with_name::<GroupDocsHandle>("GroupDocsHandle");
|
||||
|
||||
register_group_create(engine);
|
||||
register_group_get(engine);
|
||||
register_group_find(engine);
|
||||
register_group_find_one(engine);
|
||||
register_group_update(engine);
|
||||
register_group_delete(engine);
|
||||
register_group_list(engine);
|
||||
}
|
||||
|
||||
fn register_create(engine: &mut RhaiEngine) {
|
||||
@@ -218,6 +258,153 @@ fn list_call(
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
// --- GroupDocsHandle methods (§11.6 shared docs collections) ---------------
|
||||
|
||||
fn register_group_create(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"create",
|
||||
|handle: &mut GroupDocsHandle, data: Map| -> Result<String, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
let json = dynamic_to_json(&Dynamic::from(data));
|
||||
let id = block_on("docs", async move {
|
||||
h.service.create(&h.cx, &h.collection, json).await
|
||||
})?;
|
||||
Ok(id.to_string())
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_group_get(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"get",
|
||||
|handle: &mut GroupDocsHandle, id: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
let parsed_id = parse_doc_id(id)?;
|
||||
let row = block_on("docs", async move {
|
||||
h.service.get(&h.cx, &h.collection, parsed_id).await
|
||||
})?;
|
||||
Ok(row.map_or(Dynamic::UNIT, |d| Dynamic::from(doc_to_map(&d))))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_group_find(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"find",
|
||||
|handle: &mut GroupDocsHandle, filter: Map| -> Result<Array, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
let json = dynamic_to_json(&Dynamic::from(filter));
|
||||
let rows = block_on("docs", async move {
|
||||
h.service.find(&h.cx, &h.collection, json).await
|
||||
})?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|d| Dynamic::from(doc_to_map(d)))
|
||||
.collect::<Vec<Dynamic>>())
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_group_find_one(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"find_one",
|
||||
|handle: &mut GroupDocsHandle, filter: Map| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
let json = dynamic_to_json(&Dynamic::from(filter));
|
||||
let row = block_on("docs", async move {
|
||||
h.service.find_one(&h.cx, &h.collection, json).await
|
||||
})?;
|
||||
Ok(row.map_or(Dynamic::UNIT, |d| Dynamic::from(doc_to_map(&d))))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_group_update(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"update",
|
||||
|handle: &mut GroupDocsHandle, id: &str, data: Map| -> Result<(), Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
let parsed_id = parse_doc_id(id)?;
|
||||
let json = dynamic_to_json(&Dynamic::from(data));
|
||||
block_on("docs", async move {
|
||||
h.service
|
||||
.update(&h.cx, &h.collection, parsed_id, json)
|
||||
.await
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_group_delete(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"delete",
|
||||
|handle: &mut GroupDocsHandle, id: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
let parsed_id = parse_doc_id(id)?;
|
||||
block_on("docs", async move {
|
||||
h.service.delete(&h.cx, &h.collection, parsed_id).await
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_group_list(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"list",
|
||||
|handle: &mut GroupDocsHandle| -> Result<Map, Box<EvalAltResult>> {
|
||||
group_list_call(handle, None, 0)
|
||||
},
|
||||
);
|
||||
engine.register_fn(
|
||||
"list",
|
||||
|handle: &mut GroupDocsHandle, args: Map| -> Result<Map, Box<EvalAltResult>> {
|
||||
let cursor = match args.get("cursor") {
|
||||
Some(d) if !d.is_unit() => {
|
||||
Some(d.clone().into_string().map_err(|_| -> Box<EvalAltResult> {
|
||||
"docs::list: 'cursor' must be a string or ()".into()
|
||||
})?)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let limit = match args.get("limit") {
|
||||
Some(d) if !d.is_unit() => {
|
||||
let n = d.as_int().map_err(|_| -> Box<EvalAltResult> {
|
||||
"docs::list: 'limit' must be an integer".into()
|
||||
})?;
|
||||
u32::try_from(n.max(0)).unwrap_or(0)
|
||||
}
|
||||
_ => 0,
|
||||
};
|
||||
group_list_call(handle, cursor, limit)
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn group_list_call(
|
||||
handle: &GroupDocsHandle,
|
||||
cursor: Option<String>,
|
||||
limit: u32,
|
||||
) -> Result<Map, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
let page = block_on("docs", async move {
|
||||
h.service
|
||||
.list(&h.cx, &h.collection, cursor.as_deref(), limit)
|
||||
.await
|
||||
})?;
|
||||
let mut m = Map::new();
|
||||
let docs: Array = page
|
||||
.docs
|
||||
.iter()
|
||||
.map(|d| Dynamic::from(doc_to_map(d)))
|
||||
.collect();
|
||||
m.insert("docs".into(), docs.into());
|
||||
m.insert(
|
||||
"next_cursor".into(),
|
||||
page.next_cursor.map_or(Dynamic::UNIT, Dynamic::from),
|
||||
);
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
/// Build the `{ id, data, created_at, updated_at }` envelope per
|
||||
/// Decision D. Scripts read user fields via `doc.data.<field>`; `id`
|
||||
/// and timestamps are direct children of the envelope.
|
||||
|
||||
@@ -24,7 +24,9 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::bridge::block_on;
|
||||
use picloud_shared::{FileMeta, FileUpdate, FilesService, NewFile, SdkCallCx, Services};
|
||||
use picloud_shared::{
|
||||
FileMeta, FileUpdate, FilesService, GroupFilesService, NewFile, SdkCallCx, Services,
|
||||
};
|
||||
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||
|
||||
/// Per-call handle captured by the Rhai SDK. Cheap to clone (two Arcs
|
||||
@@ -36,8 +38,20 @@ pub struct FilesHandle {
|
||||
cx: Arc<SdkCallCx>,
|
||||
}
|
||||
|
||||
/// §11.6 shared-collection handle, returned by `files::shared_collection(name)`.
|
||||
/// Same method surface as `FilesHandle`, but routes through the
|
||||
/// `GroupFilesService` — the owning group is resolved from `cx.app_id`'s
|
||||
/// ancestor chain inside the service (the isolation boundary).
|
||||
#[derive(Clone)]
|
||||
pub struct GroupFilesHandle {
|
||||
collection: String,
|
||||
service: Arc<dyn GroupFilesService>,
|
||||
cx: Arc<SdkCallCx>,
|
||||
}
|
||||
|
||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||
let files_service = services.files.clone();
|
||||
let group_files_service = services.group_files.clone();
|
||||
|
||||
let mut module = Module::new();
|
||||
{
|
||||
@@ -57,6 +71,23 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
||||
},
|
||||
);
|
||||
}
|
||||
{
|
||||
let group_files_service = group_files_service.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"shared_collection",
|
||||
move |name: &str| -> Result<GroupFilesHandle, Box<EvalAltResult>> {
|
||||
if name.is_empty() {
|
||||
return Err("files::shared_collection name must not be empty".into());
|
||||
}
|
||||
Ok(GroupFilesHandle {
|
||||
collection: name.to_string(),
|
||||
service: group_files_service.clone(),
|
||||
cx: cx.clone(),
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
engine.register_static_module("files", module.into());
|
||||
|
||||
engine.register_type_with_name::<FilesHandle>("FilesHandle");
|
||||
@@ -67,6 +98,15 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
||||
register_update(engine);
|
||||
register_delete(engine);
|
||||
register_list(engine);
|
||||
|
||||
// Same method names on GroupFilesHandle — Rhai dispatches by receiver type.
|
||||
engine.register_type_with_name::<GroupFilesHandle>("GroupFilesHandle");
|
||||
register_group_create(engine);
|
||||
register_group_head(engine);
|
||||
register_group_get(engine);
|
||||
register_group_update(engine);
|
||||
register_group_delete(engine);
|
||||
register_group_list(engine);
|
||||
}
|
||||
|
||||
fn register_create(engine: &mut RhaiEngine) {
|
||||
@@ -220,6 +260,163 @@ fn list_call(
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
// --- GroupFilesHandle methods (§11.6 shared files collections) -------------
|
||||
// Bodies mirror the app handle's; only the receiver type and service differ
|
||||
// (Rhai dispatches `create`/`head`/... by the handle's concrete type).
|
||||
|
||||
fn register_group_create(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"create",
|
||||
|handle: &mut GroupFilesHandle, meta: Map| -> Result<String, Box<EvalAltResult>> {
|
||||
let name = require_string(&meta, "name")?;
|
||||
let content_type = require_string(&meta, "content_type")?;
|
||||
let data = require_blob(&meta, "data")?;
|
||||
let h = handle.clone();
|
||||
let new = NewFile {
|
||||
name,
|
||||
content_type,
|
||||
data,
|
||||
};
|
||||
let id = block_on("files", async move {
|
||||
h.service.create(&h.cx, &h.collection, new).await
|
||||
})?;
|
||||
Ok(id.to_string())
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_group_head(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"head",
|
||||
|handle: &mut GroupFilesHandle, id: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
let id = id.to_string();
|
||||
let meta = block_on("files", async move {
|
||||
h.service.head(&h.cx, &h.collection, &id).await
|
||||
})?;
|
||||
Ok(meta.map_or(Dynamic::UNIT, |m| file_meta_to_map(&m).into()))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_group_get(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"get",
|
||||
|handle: &mut GroupFilesHandle, id: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
let id = id.to_string();
|
||||
let bytes = block_on("files", async move {
|
||||
h.service.get(&h.cx, &h.collection, &id).await
|
||||
})?;
|
||||
Ok(bytes.map_or(Dynamic::UNIT, Dynamic::from_blob))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_group_update(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"update",
|
||||
|handle: &mut GroupFilesHandle, id: &str, meta: Map| -> Result<(), Box<EvalAltResult>> {
|
||||
let data = require_blob(&meta, "data")?;
|
||||
let name = optional_string(&meta, "name")?;
|
||||
let content_type = optional_string(&meta, "content_type")?;
|
||||
let h = handle.clone();
|
||||
let id = id.to_string();
|
||||
let upd = FileUpdate {
|
||||
data,
|
||||
name,
|
||||
content_type,
|
||||
};
|
||||
block_on("files", async move {
|
||||
h.service.update(&h.cx, &h.collection, &id, upd).await
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_group_delete(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"delete",
|
||||
|handle: &mut GroupFilesHandle, id: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
let id = id.to_string();
|
||||
block_on("files", async move {
|
||||
h.service.delete(&h.cx, &h.collection, &id).await
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_group_list(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"list",
|
||||
|handle: &mut GroupFilesHandle| -> Result<Map, Box<EvalAltResult>> {
|
||||
group_list_call(handle, None, 0)
|
||||
},
|
||||
);
|
||||
engine.register_fn(
|
||||
"list",
|
||||
|handle: &mut GroupFilesHandle, cursor: &str| -> Result<Map, Box<EvalAltResult>> {
|
||||
group_list_call(handle, Some(cursor.to_string()), 0)
|
||||
},
|
||||
);
|
||||
engine.register_fn(
|
||||
"list",
|
||||
|handle: &mut GroupFilesHandle,
|
||||
cursor: &str,
|
||||
limit: i64|
|
||||
-> Result<Map, Box<EvalAltResult>> {
|
||||
let limit = u32::try_from(limit.max(0)).unwrap_or(0);
|
||||
group_list_call(handle, Some(cursor.to_string()), limit)
|
||||
},
|
||||
);
|
||||
engine.register_fn(
|
||||
"list",
|
||||
|handle: &mut GroupFilesHandle, opts: Map| -> Result<Map, Box<EvalAltResult>> {
|
||||
let cursor = match opts.get("cursor") {
|
||||
Some(v) if !v.is_unit() => {
|
||||
Some(v.clone().into_string().map_err(|_| -> Box<EvalAltResult> {
|
||||
"files: list cursor must be a string".into()
|
||||
})?)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let limit = match opts.get("limit") {
|
||||
Some(v) if !v.is_unit() => {
|
||||
u32::try_from(v.as_int().unwrap_or(0).max(0)).unwrap_or(0)
|
||||
}
|
||||
_ => 0,
|
||||
};
|
||||
group_list_call(handle, cursor, limit)
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn group_list_call(
|
||||
handle: &GroupFilesHandle,
|
||||
cursor: Option<String>,
|
||||
limit: u32,
|
||||
) -> Result<Map, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
let page = block_on("files", async move {
|
||||
h.service
|
||||
.list(&h.cx, &h.collection, cursor.as_deref(), limit)
|
||||
.await
|
||||
})?;
|
||||
let mut m = Map::new();
|
||||
let files: Array = page
|
||||
.files
|
||||
.iter()
|
||||
.map(|meta| Dynamic::from(file_meta_to_map(meta)))
|
||||
.collect();
|
||||
m.insert("files".into(), files.into());
|
||||
m.insert(
|
||||
"next_cursor".into(),
|
||||
page.next_cursor.map_or(Dynamic::UNIT, Dynamic::from),
|
||||
);
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
/// Render a `FileMeta` into the Rhai map shape scripts see from
|
||||
/// `head` / `list`.
|
||||
fn file_meta_to_map(meta: &FileMeta) -> Map {
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use picloud_shared::{KvService, SdkCallCx, Services};
|
||||
use picloud_shared::{GroupKvService, KvService, SdkCallCx, Services};
|
||||
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||
|
||||
use super::bridge::{block_on, dynamic_to_json, json_to_dynamic};
|
||||
@@ -42,11 +42,25 @@ pub struct KvHandle {
|
||||
cx: Arc<SdkCallCx>,
|
||||
}
|
||||
|
||||
/// §11.6 shared-collection handle, returned by `kv::shared_collection(name)`. A distinct
|
||||
/// Rhai type from `KvHandle` so the method set can diverge (e.g. shared
|
||||
/// collections never grow triggers) and a script's choice of private-vs-shared
|
||||
/// scope is explicit. Routes through the `GroupKvService`, which resolves the
|
||||
/// owning group from `cx.app_id` (the script never names a group).
|
||||
#[derive(Clone)]
|
||||
pub struct GroupKvHandle {
|
||||
collection: String,
|
||||
service: Arc<dyn GroupKvService>,
|
||||
cx: Arc<SdkCallCx>,
|
||||
}
|
||||
|
||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||
let kv_service = services.kv.clone();
|
||||
let group_kv_service = services.group_kv.clone();
|
||||
|
||||
// `kv::collection(name)` — handle constructor lives in the `kv`
|
||||
// static module so the script-visible call is `kv::collection(...)`.
|
||||
// `kv::collection(name)` / `kv::shared_collection(name)` — both constructors live in
|
||||
// the `kv` static module so the script-visible calls are `kv::collection`
|
||||
// and `kv::shared`.
|
||||
let mut module = Module::new();
|
||||
{
|
||||
let kv_service = kv_service.clone();
|
||||
@@ -65,6 +79,23 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
||||
},
|
||||
);
|
||||
}
|
||||
{
|
||||
let group_kv_service = group_kv_service.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"shared_collection",
|
||||
move |name: &str| -> Result<GroupKvHandle, Box<EvalAltResult>> {
|
||||
if name.is_empty() {
|
||||
return Err("kv::shared_collection name must not be empty".into());
|
||||
}
|
||||
Ok(GroupKvHandle {
|
||||
collection: name.to_string(),
|
||||
service: group_kv_service.clone(),
|
||||
cx: cx.clone(),
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
engine.register_static_module("kv", module.into());
|
||||
|
||||
// Methods on KvHandle — `register_fn` with `&mut KvHandle` first
|
||||
@@ -77,6 +108,15 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
||||
register_has(engine);
|
||||
register_delete(engine);
|
||||
register_list(engine);
|
||||
|
||||
// Same method names on GroupKvHandle — Rhai dispatches by receiver type.
|
||||
engine.register_type_with_name::<GroupKvHandle>("GroupKvHandle");
|
||||
|
||||
register_group_get(engine);
|
||||
register_group_set(engine);
|
||||
register_group_has(engine);
|
||||
register_group_delete(engine);
|
||||
register_group_list(engine);
|
||||
}
|
||||
|
||||
fn register_get(engine: &mut RhaiEngine) {
|
||||
@@ -174,3 +214,98 @@ fn list_call(
|
||||
);
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
// --- GroupKvHandle methods (§11.6 shared collections) ----------------------
|
||||
|
||||
fn register_group_get(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"get",
|
||||
|handle: &mut GroupKvHandle, key: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
block_on("kv", async move {
|
||||
h.service.get(&h.cx, &h.collection, key).await
|
||||
})
|
||||
.map(|opt| opt.map_or(Dynamic::UNIT, json_to_dynamic))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_group_set(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"set",
|
||||
|handle: &mut GroupKvHandle, key: &str, value: Dynamic| -> Result<(), Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
let json = dynamic_to_json(&value);
|
||||
block_on("kv", async move {
|
||||
h.service.set(&h.cx, &h.collection, key, json).await
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_group_has(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"has",
|
||||
|handle: &mut GroupKvHandle, key: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
block_on("kv", async move {
|
||||
h.service.has(&h.cx, &h.collection, key).await
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_group_delete(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"delete",
|
||||
|handle: &mut GroupKvHandle, key: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
block_on("kv", async move {
|
||||
h.service.delete(&h.cx, &h.collection, key).await
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_group_list(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"list",
|
||||
|handle: &mut GroupKvHandle| -> Result<Map, Box<EvalAltResult>> {
|
||||
group_list_call(handle, None, 0)
|
||||
},
|
||||
);
|
||||
engine.register_fn(
|
||||
"list",
|
||||
|handle: &mut GroupKvHandle, cursor: &str| -> Result<Map, Box<EvalAltResult>> {
|
||||
group_list_call(handle, Some(cursor.to_string()), 0)
|
||||
},
|
||||
);
|
||||
engine.register_fn(
|
||||
"list",
|
||||
|handle: &mut GroupKvHandle, cursor: &str, limit: i64| -> Result<Map, Box<EvalAltResult>> {
|
||||
let limit = u32::try_from(limit.max(0)).unwrap_or(0);
|
||||
group_list_call(handle, Some(cursor.to_string()), limit)
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn group_list_call(
|
||||
handle: &GroupKvHandle,
|
||||
cursor: Option<String>,
|
||||
limit: u32,
|
||||
) -> Result<Map, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
let page = block_on("kv", async move {
|
||||
h.service
|
||||
.list(&h.cx, &h.collection, cursor.as_deref(), limit)
|
||||
.await
|
||||
})?;
|
||||
let mut m = Map::new();
|
||||
let keys: Array = page.keys.into_iter().map(Dynamic::from).collect();
|
||||
m.insert("keys".into(), keys.into());
|
||||
m.insert(
|
||||
"next_cursor".into(),
|
||||
page.next_cursor.map_or(Dynamic::UNIT, Dynamic::from),
|
||||
);
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//! verify the same code path the `picloud` binary runs at request
|
||||
//! time.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -736,3 +736,150 @@ async fn lexical_entry_origin_selects_app_module() {
|
||||
.expect("should execute");
|
||||
assert_eq!(resp.body, serde_json::json!(999));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// §5.5 extension points — resolver handling of the policy outcomes.
|
||||
//
|
||||
// `PolicyModuleSource` is a flat fake that overrides `resolve_policy`: an EP
|
||||
// name resolves dynamically against the inheriting app; a non-EP name resolves
|
||||
// lexically from the importing origin. This verifies the resolver maps
|
||||
// Module/NoProvider/NotFound correctly. The full chain semantics (default body
|
||||
// up-chain, nearest-declaration tie) are covered by the CLI journey tests.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Default)]
|
||||
struct PolicyModuleSource {
|
||||
modules: Mutex<HashMap<(ScriptOwner, String), ModuleScript>>,
|
||||
eps: Mutex<HashSet<String>>,
|
||||
}
|
||||
|
||||
impl PolicyModuleSource {
|
||||
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.modules.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 fn mark_ep(self: &Arc<Self>, name: &str) {
|
||||
self.eps.lock().await.insert(name.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ModuleSource for PolicyModuleSource {
|
||||
async fn resolve(
|
||||
&self,
|
||||
origin: ScriptOwner,
|
||||
name: &str,
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||
Ok(self
|
||||
.modules
|
||||
.lock()
|
||||
.await
|
||||
.get(&(origin, name.to_string()))
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn resolve_policy(
|
||||
&self,
|
||||
origin: ScriptOwner,
|
||||
inheriting_app: AppId,
|
||||
name: &str,
|
||||
) -> Result<picloud_shared::ModuleResolution, ModuleSourceError> {
|
||||
use picloud_shared::ModuleResolution;
|
||||
if self.eps.lock().await.contains(name) {
|
||||
// Extension point → dynamic, resolved against the inheriting app.
|
||||
return Ok(
|
||||
match self.resolve(ScriptOwner::App(inheriting_app), name).await? {
|
||||
Some(m) => ModuleResolution::Module(m),
|
||||
None => ModuleResolution::NoProvider,
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(match self.resolve(origin, name).await? {
|
||||
Some(m) => ModuleResolution::Module(m),
|
||||
None => ModuleResolution::NotFound,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// An extension-point import binds the inheriting APP's module even though the
|
||||
/// importing script's defining node is the group (dynamic override — the
|
||||
/// inverse of the Phase 4b sealed/lexical import).
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn extension_point_resolves_app_override() {
|
||||
let source = PolicyModuleSource::new();
|
||||
let app = AppId::new();
|
||||
let group = GroupId::new();
|
||||
source.mark_ep("theme").await;
|
||||
// App provides its own `theme`; group has none.
|
||||
source
|
||||
.put(ScriptOwner::App(app), "theme", r#"fn color() { "red" }"#)
|
||||
.await;
|
||||
|
||||
let engine = engine_with(source.clone());
|
||||
// Entry runs as the inherited GROUP endpoint importing the EP `theme`.
|
||||
let resp = engine
|
||||
.execute(
|
||||
r#"import "theme" as t; t::color()"#,
|
||||
req_with_owner(app, ScriptOwner::Group(group)),
|
||||
)
|
||||
.expect("should execute");
|
||||
assert_eq!(resp.body, serde_json::json!("red"));
|
||||
}
|
||||
|
||||
/// An extension point with no provider for the app is a hard error.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn extension_point_without_provider_errors() {
|
||||
let source = PolicyModuleSource::new();
|
||||
let app = AppId::new();
|
||||
let group = GroupId::new();
|
||||
source.mark_ep("theme").await; // declared, but no module anywhere.
|
||||
|
||||
let engine = engine_with(source.clone());
|
||||
let err = engine
|
||||
.execute(
|
||||
r#"import "theme" as t; t::color()"#,
|
||||
req_with_owner(app, ScriptOwner::Group(group)),
|
||||
)
|
||||
.expect_err("missing provider must error");
|
||||
let msg = format!("{err:?}").to_lowercase();
|
||||
assert!(
|
||||
msg.contains("no provider") || msg.contains("extension point"),
|
||||
"expected a no-provider error, got {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A non-extension-point name still resolves lexically from the origin.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn non_extension_point_stays_lexical() {
|
||||
let source = PolicyModuleSource::new();
|
||||
let app = AppId::new();
|
||||
let group = GroupId::new();
|
||||
source
|
||||
.put(ScriptOwner::Group(group), "util", r#"fn v() { 7 }"#)
|
||||
.await;
|
||||
|
||||
let engine = engine_with(source.clone());
|
||||
let resp = engine
|
||||
.execute(
|
||||
r#"import "util" as u; u::v()"#,
|
||||
req_with_owner(app, ScriptOwner::Group(group)),
|
||||
)
|
||||
.expect("should execute");
|
||||
assert_eq!(resp.body, serde_json::json!(7));
|
||||
}
|
||||
|
||||
40
crates/manager-core/migrations/0051_extension_points.sql
Normal file
40
crates/manager-core/migrations/0051_extension_points.sql
Normal file
@@ -0,0 +1,40 @@
|
||||
-- §5.5 (v1.2 Hierarchies): extension-point markers — opt-in module polymorphism.
|
||||
--
|
||||
-- An extension point marks a module NAME at a node (app or group) as one
|
||||
-- descendants are *expected* to provide or override. Imports of an EP name
|
||||
-- resolve DYNAMICALLY against the inheriting app's view (the app can supply
|
||||
-- its own module), instead of the Phase 4b lexical default (sealed to the
|
||||
-- importing script's defining node). See docs §5.5.
|
||||
--
|
||||
-- This table holds only the MARKER (owner, name) — it is pure declaration,
|
||||
-- structurally identical to a `secrets` name. The optional DEFAULT BODY is
|
||||
-- just a co-located `kind = 'module'` script of the same name at this node
|
||||
-- (Phase 4b already stores/resolves/caches those); there is no body column
|
||||
-- here. So a marker is config, not code → ON DELETE CASCADE (unlike the
|
||||
-- module body's RESTRICT in 0050).
|
||||
--
|
||||
-- Ownership is polymorphic (mirrors vars/secrets/scripts): exactly one of
|
||||
-- (app_id, group_id) is set, with per-owner partial-unique LOWER(name)
|
||||
-- indexes for case-insensitive uniqueness.
|
||||
|
||||
CREATE TABLE extension_points (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
|
||||
app_id UUID REFERENCES apps(id) ON DELETE CASCADE,
|
||||
CONSTRAINT extension_points_owner_exactly_one
|
||||
CHECK ((group_id IS NULL) <> (app_id IS NULL)),
|
||||
name TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- One marker per (owner, name), case-insensitive. Partial because the owner
|
||||
-- is split across two nullable columns.
|
||||
CREATE UNIQUE INDEX extension_points_group_uidx
|
||||
ON extension_points (group_id, LOWER(name)) WHERE group_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX extension_points_app_uidx
|
||||
ON extension_points (app_id, LOWER(name)) WHERE app_id IS NOT NULL;
|
||||
|
||||
-- Lookup indexes for the resolver's chain join + list-by-owner.
|
||||
CREATE INDEX extension_points_group_id_idx ON extension_points (group_id) WHERE group_id IS NOT NULL;
|
||||
CREATE INDEX extension_points_app_id_idx ON extension_points (app_id) WHERE app_id IS NOT NULL;
|
||||
47
crates/manager-core/migrations/0052_group_collections.sql
Normal file
47
crates/manager-core/migrations/0052_group_collections.sql
Normal file
@@ -0,0 +1,47 @@
|
||||
-- §11.6 (v1.2 Hierarchies): group-collection registry — shared cross-app data.
|
||||
--
|
||||
-- A row marks a collection NAME at a node as group-SHARED: every app in the
|
||||
-- owning group's subtree may read it (and, with an authenticated editor+,
|
||||
-- write it) via the explicit `kv::shared_collection("name")` SDK handle. Resolution is
|
||||
-- nearest-ancestor-group-wins, walking the reading app's chain — that ancestry
|
||||
-- walk is the isolation boundary (a foreign app's chain never contains the
|
||||
-- owning group, so the name simply does not resolve). See docs §11.6.
|
||||
--
|
||||
-- This table holds only the MARKER (owner, name, kind) — the data itself lives
|
||||
-- in a per-kind storage table (`group_kv_entries`, 0053, for kind='kv'). It is
|
||||
-- pure declaration, structurally identical to an `extension_points` marker
|
||||
-- (0051). A marker is config, not code → ON DELETE CASCADE.
|
||||
--
|
||||
-- Ownership is polymorphic (mirrors vars/secrets/scripts/extension_points):
|
||||
-- exactly one of (app_id, group_id) is set. MVP authoring is restricted to
|
||||
-- GROUP owners at the manifest layer (an app-declared shared collection is
|
||||
-- degenerate — only that app would read it); the polymorphic shape keeps the
|
||||
-- owner-generic reconcile path uniform with the other inheritable kinds.
|
||||
--
|
||||
-- `kind` is the storage discriminator. Only 'kv' ships in the MVP; the column
|
||||
-- + CHECK exist now so docs/files/topics/queue slot in later without a
|
||||
-- migration, and so a future `docs` collection of the same name is a distinct
|
||||
-- row (the unique index covers `kind`).
|
||||
|
||||
CREATE TABLE group_collections (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
|
||||
app_id UUID REFERENCES apps(id) ON DELETE CASCADE,
|
||||
CONSTRAINT group_collections_owner_exactly_one
|
||||
CHECK ((group_id IS NULL) <> (app_id IS NULL)),
|
||||
name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'kv' CHECK (kind IN ('kv')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- One marker per (owner, name, kind), case-insensitive. Partial because the
|
||||
-- owner is split across two nullable columns.
|
||||
CREATE UNIQUE INDEX group_collections_group_uidx
|
||||
ON group_collections (group_id, LOWER(name), kind) WHERE group_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX group_collections_app_uidx
|
||||
ON group_collections (app_id, LOWER(name), kind) WHERE app_id IS NOT NULL;
|
||||
|
||||
-- Lookup indexes for the resolver's chain join + list-by-owner.
|
||||
CREATE INDEX group_collections_group_id_idx ON group_collections (group_id) WHERE group_id IS NOT NULL;
|
||||
CREATE INDEX group_collections_app_id_idx ON group_collections (app_id) WHERE app_id IS NOT NULL;
|
||||
30
crates/manager-core/migrations/0053_group_kv_entries.sql
Normal file
30
crates/manager-core/migrations/0053_group_kv_entries.sql
Normal file
@@ -0,0 +1,30 @@
|
||||
-- §11.6 (v1.2 Hierarchies): group-KV storage — the shared read/write data for
|
||||
-- a collection declared group-shared in `group_collections` (0052, kind='kv').
|
||||
--
|
||||
-- Identity tuple `(group_id, collection, key)`. Unlike per-app `kv_entries`
|
||||
-- (0007) this is keyed by the OWNING GROUP, not by app — the whole point is
|
||||
-- that many apps in the group's subtree share one store. There is no `app_id`
|
||||
-- column: a shared row belongs to the group, not to whichever app wrote it.
|
||||
--
|
||||
-- `value` is JSONB, mirroring `kv_entries`. The reading/writing app is resolved
|
||||
-- to its owning group at the service layer (walking the app's ancestor chain);
|
||||
-- this table only ever sees a concrete `group_id`.
|
||||
--
|
||||
-- ON DELETE CASCADE on group_id: the shared store is DATA, so it dies with its
|
||||
-- owning group — like `vars`/`secrets` config CASCADE, deliberately UNLIKE the
|
||||
-- `scripts` (0050) RESTRICT (code is not data). Deleting an app does NOT touch
|
||||
-- this table (the data is the group's, and survives the app's departure).
|
||||
|
||||
CREATE TABLE group_kv_entries (
|
||||
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
collection TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (group_id, collection, key)
|
||||
);
|
||||
|
||||
-- Supports list-by-collection (keyset pagination); the PK already covers
|
||||
-- (group_id, collection) as a prefix but the explicit index makes intent clear.
|
||||
CREATE INDEX idx_group_kv_entries_group_collection ON group_kv_entries (group_id, collection);
|
||||
33
crates/manager-core/migrations/0054_group_docs.sql
Normal file
33
crates/manager-core/migrations/0054_group_docs.sql
Normal file
@@ -0,0 +1,33 @@
|
||||
-- §11.6 (cont., v1.2 Hierarchies): group-shared DOCS collections.
|
||||
--
|
||||
-- Extends the shared-collection machinery (0052 registry + 0053 group_kv_entries)
|
||||
-- from KV to the queryable-JSON `docs` store. The registry's `kind`
|
||||
-- discriminator was built for exactly this — widen its CHECK to admit 'docs',
|
||||
-- and add a group-keyed storage table mirroring `docs` (0013).
|
||||
|
||||
-- Widen the marker kind allow-list. The constraint was an inline column CHECK,
|
||||
-- so Postgres auto-named it `group_collections_kind_check`.
|
||||
ALTER TABLE group_collections
|
||||
DROP CONSTRAINT group_collections_kind_check,
|
||||
ADD CONSTRAINT group_collections_kind_check CHECK (kind IN ('kv', 'docs'));
|
||||
|
||||
-- Group-keyed docs store. Identity tuple `(group_id, collection, id)` — keyed
|
||||
-- by the OWNING GROUP, not an app (the shared rows belong to the group). `id`
|
||||
-- is a server-generated UUID, as in `docs`. CASCADE on group delete (data dies
|
||||
-- with its group; an app delete leaves it), matching group_kv_entries (0053).
|
||||
CREATE TABLE group_docs (
|
||||
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
collection TEXT NOT NULL,
|
||||
id UUID NOT NULL,
|
||||
data JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (group_id, collection, id)
|
||||
);
|
||||
|
||||
-- "all docs in group X / collection Y" — mirrors idx_docs_app_collection (0013).
|
||||
CREATE INDEX idx_group_docs_group_collection ON group_docs (group_id, collection);
|
||||
|
||||
-- GIN on JSONB (jsonb_path_ops) for the find DSL's equality/containment, same
|
||||
-- as idx_docs_data_gin (0013).
|
||||
CREATE INDEX idx_group_docs_data_gin ON group_docs USING GIN (data jsonb_path_ops);
|
||||
37
crates/manager-core/migrations/0055_group_files.sql
Normal file
37
crates/manager-core/migrations/0055_group_files.sql
Normal file
@@ -0,0 +1,37 @@
|
||||
-- §11.6 (cont., v1.2 Hierarchies): group-shared FILES collections.
|
||||
--
|
||||
-- Extends the shared-collection machinery (0052 registry + the per-kind stores
|
||||
-- 0053 group_kv_entries / 0054 group_docs) from KV/docs to the filesystem-backed
|
||||
-- `files` blob store. The registry's `kind` discriminator was built for exactly
|
||||
-- this — widen its CHECK to admit 'files', and add a group-keyed metadata table
|
||||
-- mirroring `files` (0018).
|
||||
|
||||
-- Widen the marker kind allow-list (auto-named `group_collections_kind_check`,
|
||||
-- per 0052's inline column CHECK; 0054 widened it to ('kv','docs')).
|
||||
ALTER TABLE group_collections
|
||||
DROP CONSTRAINT group_collections_kind_check,
|
||||
ADD CONSTRAINT group_collections_kind_check CHECK (kind IN ('kv', 'docs', 'files'));
|
||||
|
||||
-- Group-keyed file metadata. Identity tuple `(group_id, collection, id)` — keyed
|
||||
-- by the OWNING GROUP, not an app (the shared blobs belong to the group). The
|
||||
-- bytes live on disk at
|
||||
-- <PICLOUD_FILES_ROOT>/files/groups/<group_id>/<collection>/<id[0:2]>/<id>
|
||||
-- (a `groups/` infix disjoint from the per-app `files/<app_id>/...` subtree, so
|
||||
-- the orphan sweeper covers both with one walk). CASCADE on group delete (the
|
||||
-- metadata dies with its group; an app delete leaves it), matching 0053/0054.
|
||||
CREATE TABLE group_files (
|
||||
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
collection TEXT NOT NULL,
|
||||
id UUID NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL,
|
||||
size_bytes BIGINT NOT NULL,
|
||||
checksum_sha256 TEXT NOT NULL, -- hex, 64 chars, lowercase
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (group_id, collection, id)
|
||||
);
|
||||
|
||||
-- List + cursor pagination scans by (group_id, collection) — mirrors
|
||||
-- idx_files_app_collection (0018).
|
||||
CREATE INDEX idx_group_files_group_collection ON group_files (group_id, collection);
|
||||
@@ -8,7 +8,7 @@ use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::post,
|
||||
routing::{get, post},
|
||||
Extension, Json, Router,
|
||||
};
|
||||
use picloud_shared::{AppId, GroupId, Principal};
|
||||
@@ -17,8 +17,8 @@ use serde_json::json;
|
||||
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::apply_service::{
|
||||
ApplyError, ApplyOwner, ApplyReport, ApplyService, Bundle, BundleTrigger, NodeKind, PlanResult,
|
||||
TreeBundle, TreePlanResult,
|
||||
ApplyError, ApplyOwner, ApplyReport, ApplyService, Bundle, BundleTrigger, CollectionInfo,
|
||||
ExtensionPointInfo, NodeKind, PlanResult, TreeBundle, TreePlanResult,
|
||||
};
|
||||
use crate::authz::{require, AuthzDenied, Capability};
|
||||
use crate::group_repo::GroupRepository;
|
||||
@@ -32,9 +32,72 @@ pub fn apply_router(service: ApplyService) -> Router {
|
||||
.route("/groups/{id}/apply", post(group_apply_handler))
|
||||
.route("/tree/plan", post(tree_plan_handler))
|
||||
.route("/tree/apply", post(tree_apply_handler))
|
||||
.route(
|
||||
"/apps/{id}/extension-points",
|
||||
get(app_extension_points_handler),
|
||||
)
|
||||
.route(
|
||||
"/groups/{id}/extension-points",
|
||||
get(group_extension_points_handler),
|
||||
)
|
||||
.route("/groups/{id}/collections", get(group_collections_handler))
|
||||
.with_state(service)
|
||||
}
|
||||
|
||||
/// Read-only §11.6 shared-collection report for a group: its own declared
|
||||
/// shared KV collection names. Viewer-tier read.
|
||||
async fn group_collections_handler(
|
||||
State(svc): State<ApplyService>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<Json<Vec<CollectionInfo>>, ApplyError> {
|
||||
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupScriptsRead(group_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
let report = svc.collection_report(ApplyOwner::Group(group_id)).await?;
|
||||
Ok(Json(report))
|
||||
}
|
||||
|
||||
/// Read-only §5.5 extension-point report for an app: every EP visible on its
|
||||
/// chain, each with `declared_here` + resolved `provider`. Viewer-tier read.
|
||||
async fn app_extension_points_handler(
|
||||
State(svc): State<ApplyService>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<Json<Vec<ExtensionPointInfo>>, ApplyError> {
|
||||
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
|
||||
require(svc.authz.as_ref(), &principal, Capability::AppRead(app_id))
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
let report = svc.extension_point_report(ApplyOwner::App(app_id)).await?;
|
||||
Ok(Json(report))
|
||||
}
|
||||
|
||||
/// Read-only §5.5 extension-point report for a group: its own declared names.
|
||||
async fn group_extension_points_handler(
|
||||
State(svc): State<ApplyService>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<Json<Vec<ExtensionPointInfo>>, ApplyError> {
|
||||
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupScriptsRead(group_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
let report = svc
|
||||
.extension_point_report(ApplyOwner::Group(group_id))
|
||||
.await?;
|
||||
Ok(Json(report))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ApplyRequest {
|
||||
pub bundle: Bundle,
|
||||
|
||||
@@ -32,8 +32,8 @@ use std::sync::Arc;
|
||||
use picloud_orchestrator_core::routing::{pattern, RouteTable};
|
||||
use picloud_shared::{
|
||||
AdminUserId, AppId, DispatchMode, DocsEventOp, FilesEventOp, GroupId, HostKind, KvEventOp,
|
||||
MasterKey, PathKind, Route, Script, ScriptId, ScriptKind, ScriptSandbox, ScriptValidator,
|
||||
TriggerId,
|
||||
MasterKey, PathKind, Route, Script, ScriptId, ScriptKind, ScriptOwner, ScriptSandbox,
|
||||
ScriptValidator, TriggerId,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
@@ -83,8 +83,35 @@ pub struct Bundle {
|
||||
/// not expressible here; use `pic vars set --tombstone`.
|
||||
#[serde(default)]
|
||||
pub vars: std::collections::BTreeMap<String, serde_json::Value>,
|
||||
/// Declared extension-point *names* (§5.5) — module names this node offers
|
||||
/// for descendants to provide/override. Name-only, like `secrets`; the
|
||||
/// optional default body is a co-located `kind = module` script.
|
||||
#[serde(default)]
|
||||
pub extension_points: Vec<String>,
|
||||
/// Declared shared group collections (§11.6) — `(name, kind)` markers this
|
||||
/// node offers as cross-app-shared (`kind` ∈ `kv`/`docs`). The data lives in
|
||||
/// the per-kind store (`group_kv_entries` / `group_docs`). Authored on
|
||||
/// `[group]` nodes only (the CLI rejects it on `[app]`).
|
||||
#[serde(default)]
|
||||
pub collections: Vec<CollectionSpec>,
|
||||
}
|
||||
|
||||
/// One declared shared-collection marker on the wire: a name + its store kind.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct CollectionSpec {
|
||||
pub name: String,
|
||||
/// Storage kind; defaults to `kv` for back-compat with the name-only form.
|
||||
#[serde(default = "default_collection_kind")]
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
fn default_collection_kind() -> String {
|
||||
"kv".to_string()
|
||||
}
|
||||
|
||||
/// The shared-collection kinds the apply engine accepts.
|
||||
const COLLECTION_KINDS: &[&str] = &["kv", "docs", "files"];
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct BundleScript {
|
||||
pub name: String,
|
||||
@@ -315,6 +342,10 @@ pub struct Plan {
|
||||
pub secrets: Vec<ResourceChange>,
|
||||
#[serde(default)]
|
||||
pub vars: Vec<ResourceChange>,
|
||||
#[serde(default)]
|
||||
pub extension_points: Vec<ResourceChange>,
|
||||
#[serde(default)]
|
||||
pub collections: Vec<ResourceChange>,
|
||||
}
|
||||
|
||||
impl Plan {
|
||||
@@ -327,6 +358,8 @@ impl Plan {
|
||||
.chain(&self.triggers)
|
||||
.chain(&self.secrets)
|
||||
.chain(&self.vars)
|
||||
.chain(&self.extension_points)
|
||||
.chain(&self.collections)
|
||||
.all(|c| c.op == Op::NoOp)
|
||||
}
|
||||
}
|
||||
@@ -403,6 +436,29 @@ pub struct CurrentState {
|
||||
/// the manifest reconciles. Inherited group vars and tombstones are
|
||||
/// excluded (the manifest manages only the app's own real values).
|
||||
pub vars: Vec<(String, serde_json::Value)>,
|
||||
/// Extension-point marker names declared directly at this node (§5.5).
|
||||
pub extension_point_names: Vec<String>,
|
||||
/// Shared group collections declared directly at this node (§11.6), as
|
||||
/// `(name, kind)` pairs.
|
||||
pub collections: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// One row of the read-only extension-point report (§5.5).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ExtensionPointInfo {
|
||||
pub name: String,
|
||||
/// True when this node owns the marker (vs inheriting it from an ancestor).
|
||||
pub declared_here: bool,
|
||||
/// Resolved provider for an app (`app override` / `inherited default`), or
|
||||
/// `None` when unset (no provider — a plan error) or for a group node.
|
||||
pub provider: Option<String>,
|
||||
}
|
||||
|
||||
/// One row of the read-only §11.6 shared-collection report.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CollectionInfo {
|
||||
pub name: String,
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -489,6 +545,7 @@ impl ApplyService {
|
||||
let inherited = self.resolve_inherited_targets_for(owner, bundle).await?;
|
||||
self.validate_bundle_for(owner, bundle, &keys_set(&inherited))?;
|
||||
self.check_imports_resolve(owner, bundle).await?;
|
||||
self.check_extension_points_provided(owner, bundle).await?;
|
||||
if let ApplyOwner::App(app_id) = owner {
|
||||
self.validate_route_hosts(app_id, bundle).await?;
|
||||
}
|
||||
@@ -537,6 +594,19 @@ impl ApplyService {
|
||||
));
|
||||
}
|
||||
}
|
||||
// §11.6: shared collections are owned by GROUPS. Reject them on an app
|
||||
// node — the inverse of the group route/trigger guard above. The CLI
|
||||
// already prevents this (`ManifestApp` has no `collections` field), so
|
||||
// this is defense-in-depth against a hand-rolled wire `Bundle`; without
|
||||
// it the reconcile would insert an inert `app_id`-owned marker row that
|
||||
// no resolver ever reads.
|
||||
if matches!(owner, ApplyOwner::App(_)) && !bundle.collections.is_empty() {
|
||||
return Err(ApplyError::Invalid(
|
||||
"an app manifest cannot declare shared collections — \
|
||||
they are owned by a group"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
self.validate_bundle(bundle, inherited_endpoints)
|
||||
}
|
||||
|
||||
@@ -773,6 +843,40 @@ impl ApplyService {
|
||||
}
|
||||
}
|
||||
|
||||
// 3c. Extension-point markers (§5.5) — insert each Create (idempotent).
|
||||
// Name-only identity, so there is no Update; deletes happen in prune.
|
||||
for ch in &plan.extension_points {
|
||||
if ch.op == Op::Create {
|
||||
crate::extension_point_repo::insert_extension_point_tx(
|
||||
&mut *tx,
|
||||
owner.as_script_owner(),
|
||||
&ch.key,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||
report.extension_points_created += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 3d. Shared group-collection markers (§11.6) — insert each Create
|
||||
// (idempotent). Name-only identity; deletes happen in prune. Pruning a
|
||||
// marker hides the store but does NOT drop its `group_kv_entries` data
|
||||
// (that dies only with the owning group).
|
||||
for ch in &plan.collections {
|
||||
if ch.op == Op::Create {
|
||||
let kind = ch.detail.as_deref().unwrap_or("kv");
|
||||
crate::group_collection_repo::insert_collection_tx(
|
||||
&mut *tx,
|
||||
owner.as_script_owner(),
|
||||
&ch.key,
|
||||
kind,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||
report.collections_created += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Prune (only with --prune): delete stale triggers, then scripts
|
||||
// (route removals already happened in the route delete-pass above).
|
||||
// Secret pruning is deliberately deferred (destructive + irreversible):
|
||||
@@ -848,6 +952,38 @@ impl ApplyService {
|
||||
report.vars_deleted += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Extension-point markers are prunable config too (§5.5).
|
||||
for ch in &plan.extension_points {
|
||||
if ch.op == Op::Delete {
|
||||
crate::extension_point_repo::delete_extension_point_tx(
|
||||
&mut *tx,
|
||||
owner.as_script_owner(),
|
||||
&ch.key,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||
report.extension_points_deleted += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Shared group-collection markers are prunable config too (§11.6).
|
||||
// Only the marker is removed here — the data survives until the
|
||||
// owning group is deleted.
|
||||
for ch in &plan.collections {
|
||||
if ch.op == Op::Delete {
|
||||
let kind = ch.detail.as_deref().unwrap_or("kv");
|
||||
crate::group_collection_repo::delete_collection_tx(
|
||||
&mut *tx,
|
||||
owner.as_script_owner(),
|
||||
&ch.key,
|
||||
kind,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||
report.collections_deleted += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(name_to_id)
|
||||
}
|
||||
@@ -887,6 +1023,7 @@ impl ApplyService {
|
||||
let inherited = self.resolve_inherited_targets_for(owner, bundle).await?;
|
||||
self.validate_bundle_for(owner, bundle, &keys_set(&inherited))?;
|
||||
self.check_imports_resolve(owner, bundle).await?;
|
||||
self.check_extension_points_provided(owner, bundle).await?;
|
||||
if let ApplyOwner::App(app_id) = owner {
|
||||
self.validate_route_hosts(app_id, bundle).await?;
|
||||
}
|
||||
@@ -1224,6 +1361,22 @@ impl ApplyService {
|
||||
});
|
||||
}
|
||||
|
||||
// §5.5 / Phase 4b: the single-node path runs `check_imports_resolve` and
|
||||
// `check_extension_points_provided`, but the tree path cannot — a node
|
||||
// may legitimately import a module another in-tree (uncommitted) node
|
||||
// declares, so a pool-based provider check would false-positive. Both
|
||||
// lean on the runtime backstop here. Log it so the gap isn't silent.
|
||||
if prepared
|
||||
.iter()
|
||||
.any(|p| matches!(p.owner, ApplyOwner::App(_)))
|
||||
{
|
||||
tracing::debug!(
|
||||
nodes = prepared.len(),
|
||||
"tree apply: per-node import / extension-point provider checks \
|
||||
deferred to the runtime backstop (single-node-only at plan time)"
|
||||
);
|
||||
}
|
||||
|
||||
// Fold in every in-scope group's structure version, so a reparent or a
|
||||
// new app under the subtree between plan and apply trips StateMoved.
|
||||
for gid in &versioned_groups {
|
||||
@@ -1599,10 +1752,144 @@ impl ApplyService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read-only extension-point report for a node (§5.5). For an **app**:
|
||||
/// every EP visible on its chain, each flagged `declared_here` (owned by
|
||||
/// the app, vs inherited) with its resolved `provider` (`app override` /
|
||||
/// `inherited default` / `null` = unset). For a **group**: its own declared
|
||||
/// names (no single app to resolve a provider against). Backs the read-only
|
||||
/// `extension-points ls` and the `pull` round-trip.
|
||||
pub async fn extension_point_report(
|
||||
&self,
|
||||
owner: ApplyOwner,
|
||||
) -> Result<Vec<ExtensionPointInfo>, ApplyError> {
|
||||
let pool = &self.pool;
|
||||
match owner {
|
||||
ApplyOwner::Group(g) => {
|
||||
let names =
|
||||
crate::extension_point_repo::list_for_owner(pool, ScriptOwner::Group(g))
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||
Ok(names
|
||||
.into_iter()
|
||||
.map(|name| ExtensionPointInfo {
|
||||
name,
|
||||
declared_here: true,
|
||||
provider: None,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
ApplyOwner::App(a) => {
|
||||
let own: HashSet<String> =
|
||||
crate::extension_point_repo::list_for_owner(pool, ScriptOwner::App(a))
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
.into_iter()
|
||||
.map(|n| n.to_lowercase())
|
||||
.collect();
|
||||
let visible = crate::extension_point_repo::list_on_app_chain(pool, a)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||
let mut out = Vec::with_capacity(visible.len());
|
||||
for name in visible {
|
||||
let provider = match self
|
||||
.modules
|
||||
.resolve(ScriptOwner::App(a), &name)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
.and_then(|m| m.owner())
|
||||
{
|
||||
Some(ScriptOwner::App(_)) => Some("app override".to_string()),
|
||||
Some(ScriptOwner::Group(_)) => Some("inherited default".to_string()),
|
||||
None => None,
|
||||
};
|
||||
out.push(ExtensionPointInfo {
|
||||
declared_here: own.contains(&name.to_lowercase()),
|
||||
name,
|
||||
provider,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only §11.6 shared-collection report for a node: the shared
|
||||
/// collections (`name` + `kind`) declared directly at it. Group-only in
|
||||
/// practice (the CLI rejects app-declared collections). Backs
|
||||
/// `pic collections ls`.
|
||||
pub async fn collection_report(
|
||||
&self,
|
||||
owner: ApplyOwner,
|
||||
) -> Result<Vec<CollectionInfo>, ApplyError> {
|
||||
let rows =
|
||||
crate::group_collection_repo::list_all_for_owner(&self.pool, owner.as_script_owner())
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(name, kind)| CollectionInfo { name, kind })
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// §5.5 no-provider plan check (app nodes only). Every extension point
|
||||
/// visible to the app — declared in this bundle or inherited from an
|
||||
/// ancestor — must have a provider: a module of that name the app provides
|
||||
/// (in this bundle, or resolvable up its chain as an override or a default
|
||||
/// body). Otherwise the import would fail at runtime, so refuse at plan.
|
||||
/// Group nodes have no single inheriting app, so the check is per-app.
|
||||
/// Single-node only (the tree path leans on the runtime backstop).
|
||||
async fn check_extension_points_provided(
|
||||
&self,
|
||||
owner: ApplyOwner,
|
||||
bundle: &Bundle,
|
||||
) -> Result<(), ApplyError> {
|
||||
let ApplyOwner::App(app_id) = owner else {
|
||||
return Ok(());
|
||||
};
|
||||
// A same-apply app module provides its EP without being committed yet.
|
||||
let local: HashSet<String> = bundle
|
||||
.scripts
|
||||
.iter()
|
||||
.filter(|s| s.kind == ScriptKind::Module)
|
||||
.map(|s| s.name.to_lowercase())
|
||||
.collect();
|
||||
// EP names visible to the app: its own (this bundle) ∪ inherited.
|
||||
let mut names: HashSet<String> = bundle
|
||||
.extension_points
|
||||
.iter()
|
||||
.map(|n| n.to_lowercase())
|
||||
.collect();
|
||||
for n in crate::extension_point_repo::list_on_app_chain(&self.pool, app_id)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
{
|
||||
names.insert(n.to_lowercase());
|
||||
}
|
||||
for name in names {
|
||||
if local.contains(&name) {
|
||||
continue;
|
||||
}
|
||||
let found = self
|
||||
.modules
|
||||
.resolve(picloud_shared::ScriptOwner::App(app_id), &name)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||
if found.is_none() {
|
||||
return Err(ApplyError::Invalid(format!(
|
||||
"extension point `{name}` has no provider for this app — \
|
||||
declare a module named `{name}` here or ensure a default \
|
||||
body exists up-chain"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `inherited_endpoints` (lowercased names) are group-owned endpoint
|
||||
/// scripts reachable from the app's chain that a route/trigger may bind to
|
||||
/// even though the manifest doesn't declare them (Phase 4). They count as
|
||||
/// known endpoints for the binding checks below.
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn validate_bundle(
|
||||
&self,
|
||||
bundle: &Bundle,
|
||||
@@ -1722,6 +2009,48 @@ impl ApplyService {
|
||||
for key in bundle.vars.keys() {
|
||||
validate_var_key(key)?;
|
||||
}
|
||||
|
||||
// Extension points (§5.5): unique names; none may shadow a built-in
|
||||
// SDK namespace (same guard as module names — an `import "kv"` must
|
||||
// never resolve to a user-supplied provider).
|
||||
let mut ep_names: HashSet<String> = HashSet::new();
|
||||
for name in &bundle.extension_points {
|
||||
if !ep_names.insert(name.to_lowercase()) {
|
||||
return Err(ApplyError::Invalid(format!(
|
||||
"duplicate extension point `{name}`"
|
||||
)));
|
||||
}
|
||||
if crate::api::RESERVED_MODULE_NAMES.contains(&name.as_str()) {
|
||||
return Err(ApplyError::Invalid(format!(
|
||||
"extension point `{name}`: reserved module name \
|
||||
(shadows a built-in SDK namespace)"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Shared group collections (§11.6): a known kind + unique (name, kind).
|
||||
// No reserved-name guard — a collection name is a data namespace, not an
|
||||
// importable module, so it can't shadow an SDK namespace.
|
||||
let mut seen_collections: HashSet<(String, &str)> = HashSet::new();
|
||||
for c in &bundle.collections {
|
||||
if c.name.is_empty() {
|
||||
return Err(ApplyError::Invalid(
|
||||
"shared collection name must not be empty".into(),
|
||||
));
|
||||
}
|
||||
if !COLLECTION_KINDS.contains(&c.kind.as_str()) {
|
||||
return Err(ApplyError::Invalid(format!(
|
||||
"shared collection `{}`: unknown kind `{}` (want one of {COLLECTION_KINDS:?})",
|
||||
c.name, c.kind
|
||||
)));
|
||||
}
|
||||
if !seen_collections.insert((c.name.to_lowercase(), c.kind.as_str())) {
|
||||
return Err(ApplyError::Invalid(format!(
|
||||
"duplicate shared collection `{}` (kind `{}`)",
|
||||
c.name, c.kind
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1769,12 +2098,25 @@ impl ApplyService {
|
||||
.filter(|r| r.environment_scope == "*" && !r.is_tombstone)
|
||||
.map(|r| (r.key, r.value))
|
||||
.collect();
|
||||
// Extension-point markers declared directly at this node (§5.5).
|
||||
let extension_point_names =
|
||||
crate::extension_point_repo::list_for_owner(&self.pool, owner.as_script_owner())
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||
// Shared group-collection markers declared directly at this node
|
||||
// (§11.6), all kinds.
|
||||
let collections =
|
||||
crate::group_collection_repo::list_all_for_owner(&self.pool, owner.as_script_owner())
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||
Ok(CurrentState {
|
||||
scripts,
|
||||
routes,
|
||||
triggers,
|
||||
secret_names,
|
||||
vars,
|
||||
extension_point_names,
|
||||
collections,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1838,6 +2180,8 @@ fn compute_diff_with_names(
|
||||
triggers: diff_triggers(current, bundle, &script_name_by_id),
|
||||
secrets: diff_secrets(current, bundle),
|
||||
vars: diff_vars(current, bundle),
|
||||
extension_points: diff_extension_points(current, bundle),
|
||||
collections: diff_collections(current, bundle),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2002,6 +2346,14 @@ impl ApplyOwner {
|
||||
}
|
||||
}
|
||||
|
||||
/// As the shared [`ScriptOwner`] — for the module/extension-point repos.
|
||||
fn as_script_owner(self) -> picloud_shared::ScriptOwner {
|
||||
match self {
|
||||
Self::App(a) => picloud_shared::ScriptOwner::App(a),
|
||||
Self::Group(g) => picloud_shared::ScriptOwner::Group(g),
|
||||
}
|
||||
}
|
||||
|
||||
/// The app id, when this is an app node. Routes/triggers/email-secrets only
|
||||
/// exist on app nodes, so their reconcile paths call this with an
|
||||
/// `expect` — guarded by validation that rejects them on a group bundle.
|
||||
@@ -2308,6 +2660,85 @@ fn diff_secrets(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange>
|
||||
out
|
||||
}
|
||||
|
||||
/// Diff extension-point markers by name (§5.5). Name-only identity like
|
||||
/// secrets, but — unlike secrets — the markers are real declarations the
|
||||
/// manifest owns: a declared-but-absent name is a Create (the apply inserts
|
||||
/// it), and a live-but-undeclared name is a Delete (pruned under `--prune`),
|
||||
/// mirroring `vars`.
|
||||
fn diff_extension_points(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange> {
|
||||
let live: HashSet<&str> = current
|
||||
.extension_point_names
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.collect();
|
||||
let declared: HashSet<&str> = bundle.extension_points.iter().map(String::as_str).collect();
|
||||
|
||||
let mut out = Vec::new();
|
||||
for name in &bundle.extension_points {
|
||||
out.push(ResourceChange {
|
||||
op: if live.contains(name.as_str()) {
|
||||
Op::NoOp
|
||||
} else {
|
||||
Op::Create
|
||||
},
|
||||
key: name.clone(),
|
||||
detail: None,
|
||||
});
|
||||
}
|
||||
for name in ¤t.extension_point_names {
|
||||
if !declared.contains(name.as_str()) {
|
||||
out.push(ResourceChange {
|
||||
op: Op::Delete,
|
||||
key: name.clone(),
|
||||
detail: Some("on server, not declared".into()),
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Diff shared group-collection markers by `(name, kind)` (§11.6). A
|
||||
/// declared-but-absent marker is a Create, a live-but-undeclared one is a
|
||||
/// Delete (pruned under `--prune`). Each change carries `key = name`,
|
||||
/// `detail = Some(kind)` so the reconcile knows which store to touch and
|
||||
/// `pic plan` renders the kind. Identity is `(LOWER(name), kind)`, so the same
|
||||
/// name as both a `kv` and a `docs` collection are distinct markers.
|
||||
fn diff_collections(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange> {
|
||||
let live: HashSet<(String, &str)> = current
|
||||
.collections
|
||||
.iter()
|
||||
.map(|(n, k)| (n.to_lowercase(), k.as_str()))
|
||||
.collect();
|
||||
let declared: HashSet<(String, &str)> = bundle
|
||||
.collections
|
||||
.iter()
|
||||
.map(|c| (c.name.to_lowercase(), c.kind.as_str()))
|
||||
.collect();
|
||||
|
||||
let mut out = Vec::new();
|
||||
for c in &bundle.collections {
|
||||
out.push(ResourceChange {
|
||||
op: if live.contains(&(c.name.to_lowercase(), c.kind.as_str())) {
|
||||
Op::NoOp
|
||||
} else {
|
||||
Op::Create
|
||||
},
|
||||
key: c.name.clone(),
|
||||
detail: Some(c.kind.clone()),
|
||||
});
|
||||
}
|
||||
for (name, kind) in ¤t.collections {
|
||||
if !declared.contains(&(name.to_lowercase(), kind.as_str())) {
|
||||
out.push(ResourceChange {
|
||||
op: Op::Delete,
|
||||
key: name.clone(),
|
||||
detail: Some(kind.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Reject any email trigger whose referenced secret isn't set, so `plan` and
|
||||
/// `apply` give the same answer (apply also fails in `resolve_and_seal`, but
|
||||
/// only after taking the lock — surfacing it here keeps plan honest).
|
||||
@@ -2594,6 +3025,14 @@ pub struct ApplyReport {
|
||||
pub vars_updated: u32,
|
||||
#[serde(default)]
|
||||
pub vars_deleted: u32,
|
||||
#[serde(default)]
|
||||
pub extension_points_created: u32,
|
||||
#[serde(default)]
|
||||
pub extension_points_deleted: u32,
|
||||
#[serde(default)]
|
||||
pub collections_created: u32,
|
||||
#[serde(default)]
|
||||
pub collections_deleted: u32,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
@@ -2763,7 +3202,9 @@ fn state_token_with_names(
|
||||
+ current.routes.len()
|
||||
+ current.triggers.len()
|
||||
+ current.secret_names.len()
|
||||
+ current.vars.len(),
|
||||
+ current.vars.len()
|
||||
+ current.extension_point_names.len()
|
||||
+ current.collections.len(),
|
||||
);
|
||||
for s in ¤t.scripts {
|
||||
parts.push(format!(
|
||||
@@ -2818,6 +3259,15 @@ fn state_token_with_names(
|
||||
serde_json::to_string(value).unwrap_or_default()
|
||||
));
|
||||
}
|
||||
// Extension-point markers are name-only, like secret names.
|
||||
for n in ¤t.extension_point_names {
|
||||
parts.push(format!("ep|{n}"));
|
||||
}
|
||||
// Shared group-collection markers carry a kind (§11.6), so a kind change
|
||||
// moves the token.
|
||||
for (name, kind) in ¤t.collections {
|
||||
parts.push(format!("coll|{kind}|{name}"));
|
||||
}
|
||||
// Order-independent: sort the per-resource tokens before hashing.
|
||||
parts.sort_unstable();
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
@@ -2898,6 +3348,8 @@ mod tests {
|
||||
triggers: vec![],
|
||||
secrets: vec!["S".into()],
|
||||
vars: std::collections::BTreeMap::new(),
|
||||
extension_points: Vec::new(),
|
||||
collections: Vec::new(),
|
||||
};
|
||||
let plan = compute_diff(¤t, &bundle);
|
||||
assert_eq!(plan.scripts.len(), 1);
|
||||
@@ -2920,6 +3372,8 @@ mod tests {
|
||||
triggers: vec![],
|
||||
secrets: vec!["S".into()],
|
||||
vars: std::collections::BTreeMap::new(),
|
||||
extension_points: Vec::new(),
|
||||
collections: Vec::new(),
|
||||
};
|
||||
let plan = compute_diff(¤t, &bundle);
|
||||
assert!(plan.is_noop(), "expected all no-op, got {plan:?}");
|
||||
@@ -2937,6 +3391,8 @@ mod tests {
|
||||
triggers: vec![],
|
||||
secrets: vec![],
|
||||
vars: std::collections::BTreeMap::new(),
|
||||
extension_points: Vec::new(),
|
||||
collections: Vec::new(),
|
||||
};
|
||||
let plan = compute_diff(¤t, &bundle);
|
||||
assert_eq!(plan.scripts[0].op, Op::Update);
|
||||
@@ -2955,6 +3411,8 @@ mod tests {
|
||||
triggers: vec![],
|
||||
secrets: vec![],
|
||||
vars: std::collections::BTreeMap::new(),
|
||||
extension_points: Vec::new(),
|
||||
collections: Vec::new(),
|
||||
};
|
||||
let plan = compute_diff(¤t, &bundle);
|
||||
assert_eq!(plan.scripts[0].op, Op::Delete);
|
||||
@@ -3001,6 +3459,8 @@ mod tests {
|
||||
triggers: vec![],
|
||||
secrets: vec![],
|
||||
vars: std::collections::BTreeMap::new(),
|
||||
extension_points: Vec::new(),
|
||||
collections: Vec::new(),
|
||||
};
|
||||
let plan = compute_diff(¤t, &bundle);
|
||||
assert_eq!(plan.routes[0].op, Op::Update);
|
||||
@@ -3013,6 +3473,8 @@ mod tests {
|
||||
triggers: vec![],
|
||||
secrets: vec![],
|
||||
vars: std::collections::BTreeMap::new(),
|
||||
extension_points: Vec::new(),
|
||||
collections: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -142,6 +142,30 @@ pub enum Capability {
|
||||
/// the group; maps to `script:write` — the same tier as `AppWriteScript`
|
||||
/// for an app, lifted to the group owner.
|
||||
GroupScriptsWrite(GroupId),
|
||||
/// Read a group-owned shared KV collection (§11.6). Resolved via the group
|
||||
/// ancestor walk; viewer+ on the owning group. Maps to `script:read`. The
|
||||
/// reads-open trust model means a script with no principal (anonymous
|
||||
/// public HTTP) bypasses this check — the structural subtree boundary
|
||||
/// (the collection only resolves for apps under the owning group) is the
|
||||
/// hard isolation; this cap refines access for authenticated callers.
|
||||
GroupKvRead(GroupId),
|
||||
/// Write a group-owned shared KV collection (§11.6). editor+ on the owning
|
||||
/// group; maps to `script:write`. Unlike the read cap, a write FAILS CLOSED
|
||||
/// for an anonymous principal — mutation of shared data always requires an
|
||||
/// authenticated caller (enforced at the service layer, not by skipping).
|
||||
GroupKvWrite(GroupId),
|
||||
/// Read a group-owned shared DOCS collection (§11.6). Viewer+ on the owning
|
||||
/// group; same reads-open trust model as `GroupKvRead`.
|
||||
GroupDocsRead(GroupId),
|
||||
/// Write a group-owned shared DOCS collection (§11.6). editor+ on the owning
|
||||
/// group; fails closed for an anonymous principal, like `GroupKvWrite`.
|
||||
GroupDocsWrite(GroupId),
|
||||
/// Read a group-owned shared FILES collection (§11.6). Viewer+ on the owning
|
||||
/// group; same reads-open trust model as `GroupKvRead`.
|
||||
GroupFilesRead(GroupId),
|
||||
/// Write a group-owned shared FILES collection (§11.6). editor+ on the owning
|
||||
/// group; fails closed for an anonymous principal, like `GroupKvWrite`.
|
||||
GroupFilesWrite(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`+.
|
||||
@@ -208,7 +232,13 @@ impl Capability {
|
||||
| Self::GroupSecretsRead(_)
|
||||
| Self::GroupSecretsWrite(_)
|
||||
| Self::GroupScriptsRead(_)
|
||||
| Self::GroupScriptsWrite(_) => None,
|
||||
| Self::GroupScriptsWrite(_)
|
||||
| Self::GroupKvRead(_)
|
||||
| Self::GroupKvWrite(_)
|
||||
| Self::GroupDocsRead(_)
|
||||
| Self::GroupDocsWrite(_)
|
||||
| Self::GroupFilesRead(_)
|
||||
| Self::GroupFilesWrite(_) => None,
|
||||
Self::AppRead(id)
|
||||
| Self::AppWriteScript(id)
|
||||
| Self::AppWriteRoute(id)
|
||||
@@ -260,7 +290,10 @@ impl Capability {
|
||||
| Self::AppVarsRead(_)
|
||||
| Self::GroupRead(_)
|
||||
| Self::GroupVarsRead(_)
|
||||
| Self::GroupScriptsRead(_) => Scope::ScriptRead,
|
||||
| Self::GroupScriptsRead(_)
|
||||
| Self::GroupKvRead(_)
|
||||
| Self::GroupDocsRead(_)
|
||||
| Self::GroupFilesRead(_) => Scope::ScriptRead,
|
||||
Self::AppWriteScript(_)
|
||||
| Self::AppKvWrite(_)
|
||||
| Self::AppDocsWrite(_)
|
||||
@@ -279,6 +312,9 @@ impl Capability {
|
||||
// the admin tier below.
|
||||
| Self::GroupSecretsWrite(_)
|
||||
| Self::GroupScriptsWrite(_)
|
||||
| Self::GroupKvWrite(_)
|
||||
| Self::GroupDocsWrite(_)
|
||||
| Self::GroupFilesWrite(_)
|
||||
| Self::AppInvoke(_) => Scope::ScriptWrite,
|
||||
Self::AppWriteRoute(_) => Scope::RouteWrite,
|
||||
Self::AppManageDomains(_) => Scope::DomainManage,
|
||||
@@ -441,6 +477,34 @@ pub async fn script_gate<E>(
|
||||
}
|
||||
}
|
||||
|
||||
/// Like [`script_gate`], but **fails closed on an anonymous principal**: a
|
||||
/// script with `cx.principal == None` is rejected with `forbidden()` rather
|
||||
/// than skipped. Used for actions that must always be performed by an
|
||||
/// authenticated caller even though the surrounding service skips authz for
|
||||
/// public scripts — e.g. §11.6 group-collection WRITES (reads stay open via
|
||||
/// `script_gate`, writes require an authenticated editor+ on the owning group).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `forbidden()` when the principal is absent or the capability is
|
||||
/// denied, or `backend(repo_err.to_string())` on a repo error.
|
||||
pub async fn script_gate_require_principal<E>(
|
||||
repo: &dyn AuthzRepo,
|
||||
cx: &picloud_shared::SdkCallCx,
|
||||
cap: Capability,
|
||||
forbidden: impl FnOnce() -> E,
|
||||
backend: impl FnOnce(String) -> E,
|
||||
) -> Result<(), E> {
|
||||
let Some(principal) = cx.principal.as_ref() else {
|
||||
return Err(forbidden());
|
||||
};
|
||||
match require(repo, principal, cap).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(AuthzDenied::Denied) => Err(forbidden()),
|
||||
Err(AuthzDenied::Repo(e)) => Err(backend(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Layer 1: role-derived grant
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -466,7 +530,13 @@ async fn role_grants(
|
||||
| Capability::GroupSecretsRead(g)
|
||||
| Capability::GroupSecretsWrite(g)
|
||||
| Capability::GroupScriptsRead(g)
|
||||
| Capability::GroupScriptsWrite(g) => {
|
||||
| Capability::GroupScriptsWrite(g)
|
||||
| Capability::GroupKvRead(g)
|
||||
| Capability::GroupKvWrite(g)
|
||||
| Capability::GroupDocsRead(g)
|
||||
| Capability::GroupDocsWrite(g)
|
||||
| Capability::GroupFilesRead(g)
|
||||
| Capability::GroupFilesWrite(g) => {
|
||||
group_member_grants(repo, principal.user_id, cap, g).await
|
||||
}
|
||||
// Creating a root-level group is an instance act — members
|
||||
@@ -526,15 +596,21 @@ 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 {
|
||||
// viewer+ reads group metadata, config vars, and scripts.
|
||||
// viewer+ reads group metadata, config vars, scripts, and shared KV.
|
||||
Capability::GroupRead(_)
|
||||
| Capability::GroupVarsRead(_)
|
||||
| Capability::GroupScriptsRead(_) => true,
|
||||
// editor+ writes config vars/secrets/scripts.
|
||||
| Capability::GroupScriptsRead(_)
|
||||
| Capability::GroupKvRead(_)
|
||||
| Capability::GroupDocsRead(_)
|
||||
| Capability::GroupFilesRead(_) => true,
|
||||
// editor+ writes config vars/secrets/scripts and shared KV.
|
||||
Capability::GroupWrite(_)
|
||||
| Capability::GroupVarsWrite(_)
|
||||
| Capability::GroupSecretsWrite(_)
|
||||
| Capability::GroupScriptsWrite(_) => {
|
||||
| Capability::GroupScriptsWrite(_)
|
||||
| Capability::GroupKvWrite(_)
|
||||
| Capability::GroupDocsWrite(_)
|
||||
| Capability::GroupFilesWrite(_) => {
|
||||
matches!(role, AppRole::Editor | AppRole::AppAdmin)
|
||||
}
|
||||
// group_admin manages the group + reads secret VALUES (the
|
||||
@@ -1215,6 +1291,147 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn group_kv_caps_resolve_by_role_up_the_chain() {
|
||||
// §11.6: shared-KV read is viewer+, write is editor+, both resolved via
|
||||
// the group ancestor walk; an outsider gets nothing.
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let root = GroupId::new();
|
||||
let team = GroupId::new();
|
||||
repo.add_group(root, None).await;
|
||||
repo.add_group(team, Some(root)).await;
|
||||
|
||||
// A viewer at root can READ a descendant group's shared KV but not write.
|
||||
let viewer = principal(InstanceRole::Member);
|
||||
repo.grant_group(viewer.user_id, root, AppRole::Viewer)
|
||||
.await;
|
||||
assert!(can(&repo, &viewer, Capability::GroupKvRead(team))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_allow());
|
||||
assert_eq!(
|
||||
can(&repo, &viewer, Capability::GroupKvWrite(team))
|
||||
.await
|
||||
.unwrap(),
|
||||
Decision::Deny
|
||||
);
|
||||
|
||||
// An editor at root can WRITE it.
|
||||
let editor = principal(InstanceRole::Member);
|
||||
repo.grant_group(editor.user_id, root, AppRole::Editor)
|
||||
.await;
|
||||
assert!(can(&repo, &editor, Capability::GroupKvWrite(team))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_allow());
|
||||
|
||||
// An unrelated member gets neither.
|
||||
let outsider = principal(InstanceRole::Member);
|
||||
assert_eq!(
|
||||
can(&repo, &outsider, Capability::GroupKvRead(team))
|
||||
.await
|
||||
.unwrap(),
|
||||
Decision::Deny
|
||||
);
|
||||
|
||||
// Group caps carry no app_id, so a bound key is denied at the binding
|
||||
// layer regardless of role.
|
||||
let bound = Principal {
|
||||
user_id: AdminUserId::new(),
|
||||
instance_role: InstanceRole::Owner,
|
||||
scopes: Some(vec![Scope::ScriptWrite]),
|
||||
app_binding: Some(AppId::new()),
|
||||
};
|
||||
assert_eq!(
|
||||
can(&repo, &bound, Capability::GroupKvWrite(team))
|
||||
.await
|
||||
.unwrap(),
|
||||
Decision::Deny
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn group_docs_caps_resolve_by_role_up_the_chain() {
|
||||
// §11.6 docs: same trust shape as shared KV — read is viewer+, write is
|
||||
// editor+, resolved via the group ancestor walk.
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let root = GroupId::new();
|
||||
let team = GroupId::new();
|
||||
repo.add_group(root, None).await;
|
||||
repo.add_group(team, Some(root)).await;
|
||||
|
||||
let viewer = principal(InstanceRole::Member);
|
||||
repo.grant_group(viewer.user_id, root, AppRole::Viewer)
|
||||
.await;
|
||||
assert!(can(&repo, &viewer, Capability::GroupDocsRead(team))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_allow());
|
||||
assert_eq!(
|
||||
can(&repo, &viewer, Capability::GroupDocsWrite(team))
|
||||
.await
|
||||
.unwrap(),
|
||||
Decision::Deny
|
||||
);
|
||||
|
||||
let editor = principal(InstanceRole::Member);
|
||||
repo.grant_group(editor.user_id, root, AppRole::Editor)
|
||||
.await;
|
||||
assert!(can(&repo, &editor, Capability::GroupDocsWrite(team))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_allow());
|
||||
|
||||
let outsider = principal(InstanceRole::Member);
|
||||
assert_eq!(
|
||||
can(&repo, &outsider, Capability::GroupDocsRead(team))
|
||||
.await
|
||||
.unwrap(),
|
||||
Decision::Deny
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn group_files_caps_resolve_by_role_up_the_chain() {
|
||||
// §11.6 files: same trust shape as shared KV/docs — read is viewer+,
|
||||
// write is editor+, resolved via the group ancestor walk.
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let root = GroupId::new();
|
||||
let team = GroupId::new();
|
||||
repo.add_group(root, None).await;
|
||||
repo.add_group(team, Some(root)).await;
|
||||
|
||||
let viewer = principal(InstanceRole::Member);
|
||||
repo.grant_group(viewer.user_id, root, AppRole::Viewer)
|
||||
.await;
|
||||
assert!(can(&repo, &viewer, Capability::GroupFilesRead(team))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_allow());
|
||||
assert_eq!(
|
||||
can(&repo, &viewer, Capability::GroupFilesWrite(team))
|
||||
.await
|
||||
.unwrap(),
|
||||
Decision::Deny
|
||||
);
|
||||
|
||||
let editor = principal(InstanceRole::Member);
|
||||
repo.grant_group(editor.user_id, root, AppRole::Editor)
|
||||
.await;
|
||||
assert!(can(&repo, &editor, Capability::GroupFilesWrite(team))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_allow());
|
||||
|
||||
let outsider = principal(InstanceRole::Member);
|
||||
assert_eq!(
|
||||
can(&repo, &outsider, Capability::GroupFilesRead(team))
|
||||
.await
|
||||
.unwrap(),
|
||||
Decision::Deny
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_implicitly_manages_the_whole_group_tree() {
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
|
||||
@@ -167,7 +167,7 @@ impl DocsRepo for PostgresDocsRepo {
|
||||
collection: &str,
|
||||
filter: &DocsFilter,
|
||||
) -> Result<Vec<DocRow>, DocsRepoError> {
|
||||
let mut qb = build_find_query(app_id, collection, filter);
|
||||
let mut qb = build_find_query("docs", "app_id", app_id.into_inner(), collection, filter);
|
||||
let rows = qb.build().fetch_all(&self.pool).await?;
|
||||
rows.into_iter().map(row_to_doc).collect()
|
||||
}
|
||||
@@ -281,7 +281,7 @@ impl DocsRepo for PostgresDocsRepo {
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_doc(row: PgRow) -> Result<DocRow, DocsRepoError> {
|
||||
pub(crate) fn row_to_doc(row: PgRow) -> Result<DocRow, DocsRepoError> {
|
||||
Ok(DocRow {
|
||||
id: row.try_get("id")?,
|
||||
data: row.try_get("data")?,
|
||||
@@ -316,14 +316,22 @@ fn decode_cursor(cursor: &str) -> Result<Uuid, DocsRepoError> {
|
||||
// **No user input ever lands in the SQL text unparameterized.**
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
fn build_find_query<'a>(
|
||||
app_id: AppId,
|
||||
/// Build the `find` query for a docs store. `table` and `owner_col` are
|
||||
/// compile-time literals (`"docs"`/`"app_id"` for app docs, `"group_docs"`/
|
||||
/// `"group_id"` for §11.6 group-shared docs) — never user input, so
|
||||
/// interpolating them is injection-safe. `pub(crate)` so the group-docs repo
|
||||
/// reuses this single source for the (security-sensitive) query SQL.
|
||||
pub(crate) fn build_find_query<'a>(
|
||||
table: &'static str,
|
||||
owner_col: &'static str,
|
||||
owner_id: uuid::Uuid,
|
||||
collection: &'a str,
|
||||
filter: &'a DocsFilter,
|
||||
) -> QueryBuilder<'a, Postgres> {
|
||||
let mut qb =
|
||||
QueryBuilder::new("SELECT id, data, created_at, updated_at FROM docs WHERE app_id = ");
|
||||
qb.push_bind(app_id.into_inner());
|
||||
let mut qb = QueryBuilder::new(format!(
|
||||
"SELECT id, data, created_at, updated_at FROM {table} WHERE {owner_col} = "
|
||||
));
|
||||
qb.push_bind(owner_id);
|
||||
qb.push(" AND collection = ");
|
||||
qb.push_bind(collection);
|
||||
|
||||
@@ -448,7 +456,13 @@ mod sql_shape_tests {
|
||||
|
||||
fn sql_for(filter_json: serde_json::Value) -> String {
|
||||
let filter = parse_filter(&filter_json).unwrap();
|
||||
let qb = build_find_query(AppId::new(), "users", &filter);
|
||||
let qb = build_find_query(
|
||||
"docs",
|
||||
"app_id",
|
||||
AppId::new().into_inner(),
|
||||
"users",
|
||||
&filter,
|
||||
);
|
||||
qb.sql().to_string()
|
||||
}
|
||||
|
||||
|
||||
117
crates/manager-core/src/extension_point_repo.rs
Normal file
117
crates/manager-core/src/extension_point_repo.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
//! Extension-point markers (§5.5) — the `extension_points` table (0051).
|
||||
//!
|
||||
//! An extension point is a pure marker `(owner, name)` declaring that a module
|
||||
//! name is one descendants may provide/override (the import resolver then
|
||||
//! resolves it dynamically against the inheriting app — see
|
||||
//! [`crate::module_source`]). This module holds the read + transactional-write
|
||||
//! helpers; it mirrors the var/secret tx-function style (free functions over a
|
||||
//! `&PgPool` / `&mut Transaction`), keyed by the shared [`ScriptOwner`].
|
||||
|
||||
use picloud_shared::{AppId, ScriptOwner};
|
||||
use sqlx::{PgPool, Postgres, Transaction};
|
||||
|
||||
use crate::config_resolver::CHAIN_LEVELS_CTE;
|
||||
|
||||
/// List the EP names declared **directly at** `owner` (not inherited),
|
||||
/// case-insensitively sorted. Used by `load_current` (apply diff), the
|
||||
/// no-provider check, and the read-only `extension-points ls`.
|
||||
pub async fn list_for_owner(pool: &PgPool, owner: ScriptOwner) -> Result<Vec<String>, sqlx::Error> {
|
||||
let rows: Vec<(String,)> = match owner {
|
||||
ScriptOwner::App(a) => {
|
||||
sqlx::query_as(
|
||||
"SELECT name FROM extension_points WHERE app_id = $1 ORDER BY LOWER(name)",
|
||||
)
|
||||
.bind(a.into_inner())
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
}
|
||||
ScriptOwner::Group(g) => {
|
||||
sqlx::query_as(
|
||||
"SELECT name FROM extension_points WHERE group_id = $1 ORDER BY LOWER(name)",
|
||||
)
|
||||
.bind(g.into_inner())
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
Ok(rows.into_iter().map(|(n,)| n).collect())
|
||||
}
|
||||
|
||||
/// All distinct EP names **visible to an app** — declared at the app or any
|
||||
/// ancestor group, walking `apps.group_id → groups.parent_id`. Used by the
|
||||
/// no-provider plan check and the read-only `extension-points ls --app`.
|
||||
pub async fn list_on_app_chain(pool: &PgPool, app_id: AppId) -> Result<Vec<String>, sqlx::Error> {
|
||||
let rows: Vec<(String,)> = sqlx::query_as(&format!(
|
||||
"{CHAIN_LEVELS_CTE} \
|
||||
SELECT DISTINCT e.name FROM extension_points e \
|
||||
JOIN chain c ON (e.app_id = c.app_owner OR e.group_id = c.group_owner) \
|
||||
ORDER BY e.name",
|
||||
))
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|(n,)| n).collect())
|
||||
}
|
||||
|
||||
/// Insert an EP marker at `owner`, in the apply transaction. Idempotent: a
|
||||
/// re-apply of an already-declared name is a no-op (`ON CONFLICT DO NOTHING`),
|
||||
/// so the marker survives without a spurious version bump.
|
||||
pub async fn insert_extension_point_tx(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
owner: ScriptOwner,
|
||||
name: &str,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
match owner {
|
||||
ScriptOwner::App(a) => {
|
||||
sqlx::query(
|
||||
"INSERT INTO extension_points (app_id, name) VALUES ($1, $2) \
|
||||
ON CONFLICT (app_id, LOWER(name)) WHERE app_id IS NOT NULL DO NOTHING",
|
||||
)
|
||||
.bind(a.into_inner())
|
||||
.bind(name)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
ScriptOwner::Group(g) => {
|
||||
sqlx::query(
|
||||
"INSERT INTO extension_points (group_id, name) VALUES ($1, $2) \
|
||||
ON CONFLICT (group_id, LOWER(name)) WHERE group_id IS NOT NULL DO NOTHING",
|
||||
)
|
||||
.bind(g.into_inner())
|
||||
.bind(name)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete an EP marker at `owner` (case-insensitive), in the apply transaction.
|
||||
/// Used by `--prune` when the manifest stops declaring a name.
|
||||
pub async fn delete_extension_point_tx(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
owner: ScriptOwner,
|
||||
name: &str,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
match owner {
|
||||
ScriptOwner::App(a) => {
|
||||
sqlx::query(
|
||||
"DELETE FROM extension_points WHERE app_id = $1 AND LOWER(name) = LOWER($2)",
|
||||
)
|
||||
.bind(a.into_inner())
|
||||
.bind(name)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
ScriptOwner::Group(g) => {
|
||||
sqlx::query(
|
||||
"DELETE FROM extension_points WHERE group_id = $1 AND LOWER(name) = LOWER($2)",
|
||||
)
|
||||
.bind(g.into_inner())
|
||||
.bind(name)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -211,7 +211,7 @@ impl FsFilesRepo {
|
||||
}
|
||||
|
||||
fn final_path(&self, app_id: AppId, collection: &str, id: Uuid) -> PathBuf {
|
||||
final_path_at(&self.config.root, app_id, collection, id)
|
||||
final_path_at(&self.config.root, &app_owner_dir(app_id), collection, id)
|
||||
}
|
||||
|
||||
fn write_atomic(
|
||||
@@ -221,29 +221,53 @@ impl FsFilesRepo {
|
||||
id: Uuid,
|
||||
bytes: &[u8],
|
||||
) -> Result<String, FilesRepoError> {
|
||||
write_atomic_at(&self.config.root, app_id, collection, id, bytes)
|
||||
write_atomic_at(
|
||||
&self.config.root,
|
||||
&app_owner_dir(app_id),
|
||||
collection,
|
||||
id,
|
||||
bytes,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn shard_dir_at(root: &Path, app_id: AppId, collection: &str, id_str: &str) -> PathBuf {
|
||||
/// The owner-relative subdirectory of an **app**'s blobs under `<root>/files/`:
|
||||
/// just `<app_id>`. Group-shared blobs (§11.6) shard at `groups/<group_id>`
|
||||
/// instead (see `group_files_repo::group_owner_dir`) — a disjoint subtree under
|
||||
/// the same `files/` base, so the orphan sweeper covers both with one walk.
|
||||
pub(crate) fn app_owner_dir(app_id: AppId) -> PathBuf {
|
||||
PathBuf::from(app_id.into_inner().to_string())
|
||||
}
|
||||
|
||||
/// `<root>/files/<owner_rel>/<collection>/<id[0:2]>/`. `owner_rel` is built
|
||||
/// by the caller from a server-generated id (`<app_id>` or `groups/<group_id>`)
|
||||
/// — never user input — and `collection` is path-guarded one layer up, so no
|
||||
/// component can escape the `files/` base.
|
||||
pub(crate) fn shard_dir_at(
|
||||
root: &Path,
|
||||
owner_rel: &Path,
|
||||
collection: &str,
|
||||
id_str: &str,
|
||||
) -> PathBuf {
|
||||
root.join("files")
|
||||
.join(app_id.into_inner().to_string())
|
||||
.join(owner_rel)
|
||||
.join(collection)
|
||||
.join(&id_str[..2])
|
||||
}
|
||||
|
||||
fn final_path_at(root: &Path, app_id: AppId, collection: &str, id: Uuid) -> PathBuf {
|
||||
pub(crate) fn final_path_at(root: &Path, owner_rel: &Path, collection: &str, id: Uuid) -> PathBuf {
|
||||
let id_str = id.to_string();
|
||||
shard_dir_at(root, app_id, collection, &id_str).join(&id_str)
|
||||
shard_dir_at(root, owner_rel, collection, &id_str).join(&id_str)
|
||||
}
|
||||
|
||||
/// Steps 2–6 of the atomic-write protocol. Returns the lowercase hex
|
||||
/// SHA-256 of the bytes (computed in a single pass over the in-memory
|
||||
/// buffer — the file is never re-read). Free function so the fs
|
||||
/// mechanics are unit-testable without a Postgres pool.
|
||||
fn write_atomic_at(
|
||||
/// buffer — the file is never re-read). `pub(crate)` + owner-relative so the
|
||||
/// app and group-shared (§11.6) repos share this single source for the
|
||||
/// security-sensitive write+checksum mechanics, unit-testable without a pool.
|
||||
pub(crate) fn write_atomic_at(
|
||||
root: &Path,
|
||||
app_id: AppId,
|
||||
owner_rel: &Path,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
bytes: &[u8],
|
||||
@@ -251,7 +275,7 @@ fn write_atomic_at(
|
||||
use std::io::Write as _;
|
||||
|
||||
let id_str = id.to_string();
|
||||
let dir = shard_dir_at(root, app_id, collection, &id_str);
|
||||
let dir = shard_dir_at(root, owner_rel, collection, &id_str);
|
||||
create_dir_all_secure(&dir)?;
|
||||
|
||||
// Single-pass checksum over the in-memory buffer.
|
||||
@@ -278,15 +302,16 @@ fn write_atomic_at(
|
||||
|
||||
/// Read + checksum-verify the bytes at the given path-set. Free
|
||||
/// function mirror of the `get` read path. Returns `Corrupted` when the
|
||||
/// bytes are missing or don't match `expected_checksum`.
|
||||
fn read_verify_at(
|
||||
/// bytes are missing or don't match `expected_checksum`. `pub(crate)` +
|
||||
/// owner-relative — shared with the group-files (§11.6) repo.
|
||||
pub(crate) fn read_verify_at(
|
||||
root: &Path,
|
||||
app_id: AppId,
|
||||
owner_rel: &Path,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
expected_checksum: &str,
|
||||
) -> Result<Vec<u8>, FilesRepoError> {
|
||||
let path = final_path_at(root, app_id, collection, id);
|
||||
let path = final_path_at(root, owner_rel, collection, id);
|
||||
let bytes = match std::fs::read(&path) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
@@ -383,7 +408,13 @@ impl FilesRepo for FsFilesRepo {
|
||||
let Some((stored_checksum,)) = row else {
|
||||
return Ok(None);
|
||||
};
|
||||
let bytes = read_verify_at(&self.config.root, app_id, collection, id, &stored_checksum)?;
|
||||
let bytes = read_verify_at(
|
||||
&self.config.root,
|
||||
&app_owner_dir(app_id),
|
||||
collection,
|
||||
id,
|
||||
&stored_checksum,
|
||||
)?;
|
||||
Ok(Some(bytes))
|
||||
}
|
||||
|
||||
@@ -555,11 +586,11 @@ fn hex_lower(bytes: &[u8]) -> String {
|
||||
s
|
||||
}
|
||||
|
||||
fn encode_cursor(last_id: Uuid) -> String {
|
||||
pub(crate) fn encode_cursor(last_id: Uuid) -> String {
|
||||
URL_SAFE_NO_PAD.encode(last_id.to_string().as_bytes())
|
||||
}
|
||||
|
||||
fn decode_cursor(cursor: &str) -> Result<Uuid, FilesRepoError> {
|
||||
pub(crate) fn decode_cursor(cursor: &str) -> Result<Uuid, FilesRepoError> {
|
||||
let bytes = URL_SAFE_NO_PAD
|
||||
.decode(cursor)
|
||||
.map_err(|_| FilesRepoError::InvalidCursor)?;
|
||||
@@ -672,13 +703,13 @@ mod tests {
|
||||
let id = Uuid::new_v4();
|
||||
let bytes = b"hello picloud files".to_vec();
|
||||
|
||||
let checksum = write_atomic_at(&root, app, "avatars", id, &bytes).unwrap();
|
||||
let checksum = write_atomic_at(&root, &app_owner_dir(app), "avatars", id, &bytes).unwrap();
|
||||
// Single-pass checksum matches an independent hash of the bytes.
|
||||
let mut h = Sha256::new();
|
||||
h.update(&bytes);
|
||||
assert_eq!(checksum, hex_lower(&h.finalize()));
|
||||
|
||||
let read = read_verify_at(&root, app, "avatars", id, &checksum).unwrap();
|
||||
let read = read_verify_at(&root, &app_owner_dir(app), "avatars", id, &checksum).unwrap();
|
||||
assert_eq!(read, bytes);
|
||||
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
@@ -689,13 +720,13 @@ mod tests {
|
||||
let root = unique_tmp_root();
|
||||
let app = AppId::new();
|
||||
let id = Uuid::new_v4();
|
||||
let checksum = write_atomic_at(&root, app, "c", id, b"original").unwrap();
|
||||
let checksum = write_atomic_at(&root, &app_owner_dir(app), "c", id, b"original").unwrap();
|
||||
|
||||
// Mutate the bytes behind the repo's back.
|
||||
let path = final_path_at(&root, app, "c", id);
|
||||
let path = final_path_at(&root, &app_owner_dir(app), "c", id);
|
||||
std::fs::write(&path, b"tampered").unwrap();
|
||||
|
||||
let err = read_verify_at(&root, app, "c", id, &checksum).unwrap_err();
|
||||
let err = read_verify_at(&root, &app_owner_dir(app), "c", id, &checksum).unwrap_err();
|
||||
assert!(matches!(err, FilesRepoError::Corrupted));
|
||||
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
@@ -707,7 +738,7 @@ mod tests {
|
||||
let app = AppId::new();
|
||||
let id = Uuid::new_v4();
|
||||
// No write — the file never existed.
|
||||
let err = read_verify_at(&root, app, "c", id, "deadbeef").unwrap_err();
|
||||
let err = read_verify_at(&root, &app_owner_dir(app), "c", id, "deadbeef").unwrap_err();
|
||||
assert!(matches!(err, FilesRepoError::Corrupted));
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
@@ -717,10 +748,10 @@ mod tests {
|
||||
let root = unique_tmp_root();
|
||||
let app = AppId::new();
|
||||
let id = Uuid::new_v4();
|
||||
write_atomic_at(&root, app, "c", id, b"data").unwrap();
|
||||
write_atomic_at(&root, &app_owner_dir(app), "c", id, b"data").unwrap();
|
||||
|
||||
let id_str = id.to_string();
|
||||
let dir = shard_dir_at(&root, app, "c", &id_str);
|
||||
let dir = shard_dir_at(&root, &app_owner_dir(app), "c", &id_str);
|
||||
let entries: Vec<_> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.filter_map(Result::ok)
|
||||
@@ -739,7 +770,7 @@ mod tests {
|
||||
let app = AppId::new();
|
||||
let id = Uuid::new_v4();
|
||||
let id_str = id.to_string();
|
||||
let path = final_path_at(&root, app, "col", id);
|
||||
let path = final_path_at(&root, &app_owner_dir(app), "col", id);
|
||||
let shard = &id_str[..2];
|
||||
assert!(path
|
||||
.to_string_lossy()
|
||||
@@ -753,9 +784,9 @@ mod tests {
|
||||
let root = unique_tmp_root();
|
||||
let app = AppId::new();
|
||||
let id = Uuid::new_v4();
|
||||
write_atomic_at(&root, app, "c", id, b"data").unwrap();
|
||||
write_atomic_at(&root, &app_owner_dir(app), "c", id, b"data").unwrap();
|
||||
let id_str = id.to_string();
|
||||
let dir = shard_dir_at(&root, app, "c", &id_str);
|
||||
let dir = shard_dir_at(&root, &app_owner_dir(app), "c", &id_str);
|
||||
let mode = std::fs::metadata(&dir).unwrap().permissions().mode();
|
||||
assert_eq!(mode & 0o777, 0o700, "shard dir should be 0o700");
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
|
||||
@@ -165,6 +165,23 @@ mod tests {
|
||||
assert!(tmp.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sweeps_group_shared_tmp_files() {
|
||||
// §11.6 group-shared files shard under `files/groups/<group_id>/...`,
|
||||
// a disjoint subtree from the per-app `files/<app_id>/...`. The walk is
|
||||
// recursive from `<root>/files/`, so an orphan there is reaped without
|
||||
// any group-specific sweeper change — this pins that "covered for free"
|
||||
// guarantee against a future refactor.
|
||||
let root = tmp_root();
|
||||
let group_shard = root.join("files/groups/6f693a33-9ae5-485b-804e-191e9fd33524/assets/ab");
|
||||
std::fs::create_dir_all(&group_shard).unwrap();
|
||||
let tmp = group_shard.join("uuid.tmp.123-0");
|
||||
touch(&tmp);
|
||||
let stats = sweep_orphan_tmp_files(&root, Duration::ZERO);
|
||||
assert_eq!(stats.files_deleted, 1, "group-shared orphan must be reaped");
|
||||
assert!(!tmp.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_non_tmp_files() {
|
||||
let root = tmp_root();
|
||||
|
||||
189
crates/manager-core/src/group_collection_repo.rs
Normal file
189
crates/manager-core/src/group_collection_repo.rs
Normal file
@@ -0,0 +1,189 @@
|
||||
//! Group-collection markers (§11.6) — the `group_collections` table (0052).
|
||||
//!
|
||||
//! A marker `(owner, name, kind)` declares that a collection name is
|
||||
//! group-shared. `kind` selects the storage backend: `'kv'` →
|
||||
//! `group_kv_entries` (0053), `'docs'` → `group_docs` (0054). This module holds
|
||||
//! the read + transactional-write helpers, the runtime resolver, and the
|
||||
//! injectable [`GroupCollectionResolver`] trait the per-kind services consume.
|
||||
//!
|
||||
//! The load-bearing function is [`resolve_owning_group`]: it walks the reading
|
||||
//! app's ancestor chain ([`CHAIN_LEVELS_CTE`]) for the nearest group declaring
|
||||
//! the collection of the requested `kind`. That walk **is** the isolation
|
||||
//! boundary — a foreign app's chain never contains the owning group, so the
|
||||
//! name does not resolve.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{AppId, GroupId, ScriptOwner};
|
||||
use sqlx::{PgPool, Postgres, Transaction};
|
||||
|
||||
use crate::config_resolver::CHAIN_LEVELS_CTE;
|
||||
|
||||
/// List **all** shared-collection declarations at `owner` as `(name, kind)`
|
||||
/// pairs, sorted. Used by the kind-aware apply diff and `collections ls`.
|
||||
pub async fn list_all_for_owner(
|
||||
pool: &PgPool,
|
||||
owner: ScriptOwner,
|
||||
) -> Result<Vec<(String, String)>, sqlx::Error> {
|
||||
let rows: Vec<(String, String)> = match owner {
|
||||
ScriptOwner::App(a) => {
|
||||
sqlx::query_as(
|
||||
"SELECT name, kind FROM group_collections \
|
||||
WHERE app_id = $1 ORDER BY kind, LOWER(name)",
|
||||
)
|
||||
.bind(a.into_inner())
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
}
|
||||
ScriptOwner::Group(g) => {
|
||||
sqlx::query_as(
|
||||
"SELECT name, kind FROM group_collections \
|
||||
WHERE group_id = $1 ORDER BY kind, LOWER(name)",
|
||||
)
|
||||
.bind(g.into_inner())
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Resolve the group that OWNS the shared collection `(name, kind)` for a
|
||||
/// reading app: the nearest ancestor group on the app's chain that declares it.
|
||||
/// Returns `None` when no group on the chain shares that `(name, kind)` — the
|
||||
/// structural "not shared with you" boundary. Nearest-wins (CoW shadowing) is
|
||||
/// enforced by `ORDER BY depth ASC LIMIT 1` and is security-relevant.
|
||||
///
|
||||
/// The join is on `group_owner` only: an app-declared marker (the degenerate
|
||||
/// case) never makes a collection visible to *other* apps — sharing is a group
|
||||
/// property.
|
||||
pub async fn resolve_owning_group(
|
||||
pool: &PgPool,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
kind: &str,
|
||||
) -> Result<Option<GroupId>, sqlx::Error> {
|
||||
let row: Option<(uuid::Uuid,)> = sqlx::query_as(&format!(
|
||||
"{CHAIN_LEVELS_CTE} \
|
||||
SELECT gc.group_id FROM group_collections gc \
|
||||
JOIN chain c ON gc.group_id = c.group_owner \
|
||||
WHERE LOWER(gc.name) = LOWER($2) AND gc.kind = $3 \
|
||||
ORDER BY c.depth ASC LIMIT 1",
|
||||
))
|
||||
.bind(app_id.into_inner())
|
||||
.bind(name)
|
||||
.bind(kind)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(|(id,)| GroupId::from(id)))
|
||||
}
|
||||
|
||||
/// Insert a `(name, kind)` marker at `owner`, in the apply transaction.
|
||||
/// Idempotent: a re-apply of an already-declared marker is a no-op
|
||||
/// (`ON CONFLICT DO NOTHING`), so it survives without a spurious version bump.
|
||||
pub async fn insert_collection_tx(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
owner: ScriptOwner,
|
||||
name: &str,
|
||||
kind: &str,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
match owner {
|
||||
ScriptOwner::App(a) => {
|
||||
sqlx::query(
|
||||
"INSERT INTO group_collections (app_id, name, kind) VALUES ($1, $2, $3) \
|
||||
ON CONFLICT (app_id, LOWER(name), kind) WHERE app_id IS NOT NULL DO NOTHING",
|
||||
)
|
||||
.bind(a.into_inner())
|
||||
.bind(name)
|
||||
.bind(kind)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
ScriptOwner::Group(g) => {
|
||||
sqlx::query(
|
||||
"INSERT INTO group_collections (group_id, name, kind) VALUES ($1, $2, $3) \
|
||||
ON CONFLICT (group_id, LOWER(name), kind) WHERE group_id IS NOT NULL DO NOTHING",
|
||||
)
|
||||
.bind(g.into_inner())
|
||||
.bind(name)
|
||||
.bind(kind)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a `(name, kind)` marker at `owner` (case-insensitive), in the apply
|
||||
/// transaction. Used by `--prune`. The storage data is NOT dropped here —
|
||||
/// pruning a marker hides the store but leaves the data until the owning group
|
||||
/// is deleted.
|
||||
pub async fn delete_collection_tx(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
owner: ScriptOwner,
|
||||
name: &str,
|
||||
kind: &str,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
match owner {
|
||||
ScriptOwner::App(a) => {
|
||||
sqlx::query(
|
||||
"DELETE FROM group_collections \
|
||||
WHERE app_id = $1 AND LOWER(name) = LOWER($2) AND kind = $3",
|
||||
)
|
||||
.bind(a.into_inner())
|
||||
.bind(name)
|
||||
.bind(kind)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
ScriptOwner::Group(g) => {
|
||||
sqlx::query(
|
||||
"DELETE FROM group_collections \
|
||||
WHERE group_id = $1 AND LOWER(name) = LOWER($2) AND kind = $3",
|
||||
)
|
||||
.bind(g.into_inner())
|
||||
.bind(name)
|
||||
.bind(kind)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolves a shared-collection name+kind to its owning group for a calling app
|
||||
/// (nearest ancestor group on the app's chain that declares it). Behind a trait
|
||||
/// so the per-kind services (`GroupKvServiceImpl`, `GroupDocsServiceImpl`) can
|
||||
/// inject a fake in unit tests without Postgres.
|
||||
#[async_trait]
|
||||
pub trait GroupCollectionResolver: Send + Sync {
|
||||
async fn resolve_owning_group(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
kind: &str,
|
||||
) -> Result<Option<GroupId>, sqlx::Error>;
|
||||
}
|
||||
|
||||
/// Postgres-backed resolver — delegates to [`resolve_owning_group`].
|
||||
pub struct PostgresGroupCollectionResolver {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresGroupCollectionResolver {
|
||||
#[must_use]
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GroupCollectionResolver for PostgresGroupCollectionResolver {
|
||||
async fn resolve_owning_group(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
kind: &str,
|
||||
) -> Result<Option<GroupId>, sqlx::Error> {
|
||||
resolve_owning_group(&self.pool, app_id, name, kind).await
|
||||
}
|
||||
}
|
||||
284
crates/manager-core/src/group_docs_repo.rs
Normal file
284
crates/manager-core/src/group_docs_repo.rs
Normal file
@@ -0,0 +1,284 @@
|
||||
//! Low-level Postgres CRUD over `group_docs` (§11.6). A near-clone of
|
||||
//! [`crate::docs_repo`] keyed by the owning `group_id` instead of `app_id`.
|
||||
//! The `find` query is built by the shared [`crate::docs_repo::build_find_query`]
|
||||
//! (parameterized on table + owner column), so the security-sensitive filter
|
||||
//! SQL has a single source. Authorization, group resolution, value validation,
|
||||
//! and event policy live one layer up in `GroupDocsServiceImpl`.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine as _;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{DocId, DocRow, DocsListPage, GroupId};
|
||||
use serde_json::Value;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::docs_filter::DocsFilter;
|
||||
use crate::docs_repo::{build_find_query, row_to_doc, DocsRepoError};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GroupDocsRepoError {
|
||||
#[error("database error: {0}")]
|
||||
Db(#[from] sqlx::Error),
|
||||
|
||||
#[error("invalid pagination cursor")]
|
||||
InvalidCursor,
|
||||
}
|
||||
|
||||
impl From<DocsRepoError> for GroupDocsRepoError {
|
||||
fn from(e: DocsRepoError) -> Self {
|
||||
match e {
|
||||
DocsRepoError::Db(e) => Self::Db(e),
|
||||
DocsRepoError::InvalidCursor => Self::InvalidCursor,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait GroupDocsRepo: Send + Sync {
|
||||
async fn create(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
data: Value,
|
||||
) -> Result<DocRow, GroupDocsRepoError>;
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: DocId,
|
||||
) -> Result<Option<DocRow>, GroupDocsRepoError>;
|
||||
|
||||
async fn find(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
filter: &DocsFilter,
|
||||
) -> Result<Vec<DocRow>, GroupDocsRepoError>;
|
||||
|
||||
/// Returns the previous data (for the would-be event), `None` if missing.
|
||||
async fn update(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: DocId,
|
||||
data: Value,
|
||||
) -> Result<Option<Value>, GroupDocsRepoError>;
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: DocId,
|
||||
) -> Result<Option<Value>, GroupDocsRepoError>;
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<DocsListPage, GroupDocsRepoError>;
|
||||
}
|
||||
|
||||
pub struct PostgresGroupDocsRepo {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresGroupDocsRepo {
|
||||
#[must_use]
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
const DOCS_LIST_MAX_LIMIT: u32 = 1_000;
|
||||
const DOCS_LIST_DEFAULT_LIMIT: u32 = 100;
|
||||
|
||||
#[async_trait]
|
||||
impl GroupDocsRepo for PostgresGroupDocsRepo {
|
||||
async fn create(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
data: Value,
|
||||
) -> Result<DocRow, GroupDocsRepoError> {
|
||||
let id = Uuid::new_v4();
|
||||
let row: (DateTime<Utc>, DateTime<Utc>) = sqlx::query_as(
|
||||
"INSERT INTO group_docs (group_id, collection, id, data) \
|
||||
VALUES ($1, $2, $3, $4) \
|
||||
RETURNING created_at, updated_at",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(id)
|
||||
.bind(&data)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(DocRow {
|
||||
id,
|
||||
data,
|
||||
created_at: row.0,
|
||||
updated_at: row.1,
|
||||
})
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: DocId,
|
||||
) -> Result<Option<DocRow>, GroupDocsRepoError> {
|
||||
let row: Option<(Value, DateTime<Utc>, DateTime<Utc>)> = sqlx::query_as(
|
||||
"SELECT data, created_at, updated_at FROM group_docs \
|
||||
WHERE group_id = $1 AND collection = $2 AND id = $3",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(|(data, created_at, updated_at)| DocRow {
|
||||
id,
|
||||
data,
|
||||
created_at,
|
||||
updated_at,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn find(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
filter: &DocsFilter,
|
||||
) -> Result<Vec<DocRow>, GroupDocsRepoError> {
|
||||
let mut qb = build_find_query(
|
||||
"group_docs",
|
||||
"group_id",
|
||||
group_id.into_inner(),
|
||||
collection,
|
||||
filter,
|
||||
);
|
||||
let rows = qb.build().fetch_all(&self.pool).await?;
|
||||
rows.into_iter()
|
||||
.map(row_to_doc)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: DocId,
|
||||
data: Value,
|
||||
) -> Result<Option<Value>, GroupDocsRepoError> {
|
||||
let row: Option<(Option<Value>,)> = sqlx::query_as(
|
||||
"WITH prev AS ( \
|
||||
SELECT data FROM group_docs \
|
||||
WHERE group_id = $1 AND collection = $2 AND id = $3 \
|
||||
), \
|
||||
updated AS ( \
|
||||
UPDATE group_docs SET data = $4, updated_at = NOW() \
|
||||
WHERE group_id = $1 AND collection = $2 AND id = $3 \
|
||||
RETURNING 1 \
|
||||
) \
|
||||
SELECT (SELECT data FROM prev) FROM updated",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(id)
|
||||
.bind(&data)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.and_then(|(v,)| v))
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: DocId,
|
||||
) -> Result<Option<Value>, GroupDocsRepoError> {
|
||||
let row: Option<(Value,)> = sqlx::query_as(
|
||||
"DELETE FROM group_docs \
|
||||
WHERE group_id = $1 AND collection = $2 AND id = $3 \
|
||||
RETURNING data",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(|(v,)| v))
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<DocsListPage, GroupDocsRepoError> {
|
||||
let limit = if limit == 0 {
|
||||
DOCS_LIST_DEFAULT_LIMIT
|
||||
} else {
|
||||
limit.min(DOCS_LIST_MAX_LIMIT)
|
||||
};
|
||||
|
||||
let last_id = match cursor {
|
||||
Some(c) => Some(decode_cursor(c)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let take = i64::from(limit) + 1;
|
||||
let rows: Vec<(Uuid, Value, DateTime<Utc>, DateTime<Utc>)> = sqlx::query_as(
|
||||
"SELECT id, data, created_at, updated_at FROM group_docs \
|
||||
WHERE group_id = $1 AND collection = $2 \
|
||||
AND ($3::uuid IS NULL OR id > $3) \
|
||||
ORDER BY id ASC \
|
||||
LIMIT $4",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(last_id)
|
||||
.bind(take)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
let mut docs: Vec<DocRow> = rows
|
||||
.into_iter()
|
||||
.map(|(id, data, created_at, updated_at)| DocRow {
|
||||
id,
|
||||
data,
|
||||
created_at,
|
||||
updated_at,
|
||||
})
|
||||
.collect();
|
||||
let next_cursor = if docs.len() > limit as usize {
|
||||
docs.truncate(limit as usize);
|
||||
docs.last().map(|d| encode_cursor(&d.id))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(DocsListPage { docs, next_cursor })
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_cursor(last_id: &Uuid) -> String {
|
||||
URL_SAFE_NO_PAD.encode(last_id.as_bytes())
|
||||
}
|
||||
|
||||
fn decode_cursor(cursor: &str) -> Result<Uuid, GroupDocsRepoError> {
|
||||
let bytes = URL_SAFE_NO_PAD
|
||||
.decode(cursor)
|
||||
.map_err(|_| GroupDocsRepoError::InvalidCursor)?;
|
||||
let arr: [u8; 16] = bytes
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| GroupDocsRepoError::InvalidCursor)?;
|
||||
Ok(Uuid::from_bytes(arr))
|
||||
}
|
||||
483
crates/manager-core/src/group_docs_service.rs
Normal file
483
crates/manager-core/src/group_docs_service.rs
Normal file
@@ -0,0 +1,483 @@
|
||||
//! `GroupDocsServiceImpl` — wires `GroupDocsRepo` + the group-collection
|
||||
//! registry underneath the `picloud_shared::GroupDocsService` trait that scripts
|
||||
//! reach via the `docs::shared_collection("name")` Rhai handle (§11.6).
|
||||
//!
|
||||
//! Combines the group-KV service pattern (owner resolution from `cx.app_id`,
|
||||
//! reads-open / writes-authed authz) with the docs surface (filter parsing,
|
||||
//! JSON-object validation, value-size cap). No event emission in the MVP (the
|
||||
//! "group trigger has no app to watch" deferral).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{
|
||||
DocId, DocRow, DocsListPage, GroupDocsError, GroupDocsService, GroupId, SdkCallCx,
|
||||
};
|
||||
|
||||
use crate::authz::{self, AuthzRepo, Capability};
|
||||
use crate::docs_filter::{parse_filter, FilterParseError};
|
||||
use crate::docs_service::docs_max_value_bytes_from_env;
|
||||
use crate::group_collection_repo::GroupCollectionResolver;
|
||||
use crate::group_docs_repo::{GroupDocsRepo, GroupDocsRepoError};
|
||||
|
||||
/// The registry `kind` this service resolves.
|
||||
const KIND_DOCS: &str = "docs";
|
||||
|
||||
pub struct GroupDocsServiceImpl {
|
||||
repo: Arc<dyn GroupDocsRepo>,
|
||||
resolver: Arc<dyn GroupCollectionResolver>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
max_value_bytes: usize,
|
||||
}
|
||||
|
||||
impl GroupDocsServiceImpl {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
repo: Arc<dyn GroupDocsRepo>,
|
||||
resolver: Arc<dyn GroupCollectionResolver>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
) -> Self {
|
||||
Self::with_max_value_bytes(repo, resolver, authz, docs_max_value_bytes_from_env())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_max_value_bytes(
|
||||
repo: Arc<dyn GroupDocsRepo>,
|
||||
resolver: Arc<dyn GroupCollectionResolver>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
max_value_bytes: usize,
|
||||
) -> Self {
|
||||
Self {
|
||||
repo,
|
||||
resolver,
|
||||
authz,
|
||||
max_value_bytes,
|
||||
}
|
||||
}
|
||||
|
||||
/// The structural boundary: resolve the collection to its owning group
|
||||
/// (kind=`docs`) on the calling app's chain. `CollectionNotShared` when no
|
||||
/// ancestor group declares it.
|
||||
async fn owning_group(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
) -> Result<GroupId, GroupDocsError> {
|
||||
if collection.is_empty() {
|
||||
return Err(GroupDocsError::InvalidCollection);
|
||||
}
|
||||
self.resolver
|
||||
.resolve_owning_group(cx.app_id, collection, KIND_DOCS)
|
||||
.await
|
||||
.map_err(|e| GroupDocsError::Backend(e.to_string()))?
|
||||
.ok_or_else(|| GroupDocsError::CollectionNotShared(collection.to_string()))
|
||||
}
|
||||
|
||||
fn check_data_size(&self, data: &serde_json::Value) -> Result<(), GroupDocsError> {
|
||||
let encoded_len = serde_json::to_vec(data)
|
||||
.map(|v| v.len())
|
||||
.map_err(|e| GroupDocsError::Backend(format!("encode doc data: {e}")))?;
|
||||
if encoded_len > self.max_value_bytes {
|
||||
return Err(GroupDocsError::ValueTooLarge {
|
||||
limit: self.max_value_bytes,
|
||||
actual: encoded_len,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn check_read(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupDocsError> {
|
||||
authz::script_gate(
|
||||
&*self.authz,
|
||||
cx,
|
||||
Capability::GroupDocsRead(group_id),
|
||||
|| GroupDocsError::Forbidden,
|
||||
GroupDocsError::Backend,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn check_write(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupDocsError> {
|
||||
// Fails closed for an anonymous principal — shared mutation always needs
|
||||
// an authenticated editor+ on the owning group.
|
||||
authz::script_gate_require_principal(
|
||||
&*self.authz,
|
||||
cx,
|
||||
Capability::GroupDocsWrite(group_id),
|
||||
|| GroupDocsError::Forbidden,
|
||||
GroupDocsError::Backend,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_data(data: &serde_json::Value) -> Result<(), GroupDocsError> {
|
||||
if !data.is_object() {
|
||||
return Err(GroupDocsError::InvalidData);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl From<GroupDocsRepoError> for GroupDocsError {
|
||||
fn from(e: GroupDocsRepoError) -> Self {
|
||||
Self::Backend(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<FilterParseError> for GroupDocsError {
|
||||
fn from(e: FilterParseError) -> Self {
|
||||
match e {
|
||||
FilterParseError::InvalidFilter(s) => Self::InvalidFilter(s),
|
||||
FilterParseError::UnsupportedOperator(s) => Self::UnsupportedOperator(s),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GroupDocsService for GroupDocsServiceImpl {
|
||||
async fn create(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
data: serde_json::Value,
|
||||
) -> Result<DocId, GroupDocsError> {
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
validate_data(&data)?;
|
||||
self.check_data_size(&data)?;
|
||||
self.check_write(cx, group_id).await?;
|
||||
Ok(self.repo.create(group_id, collection, data).await?.id)
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
id: DocId,
|
||||
) -> Result<Option<DocRow>, GroupDocsError> {
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
self.check_read(cx, group_id).await?;
|
||||
Ok(self.repo.get(group_id, collection, id).await?)
|
||||
}
|
||||
|
||||
async fn find(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
filter: serde_json::Value,
|
||||
) -> Result<Vec<DocRow>, GroupDocsError> {
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
self.check_read(cx, group_id).await?;
|
||||
let parsed = parse_filter(&filter)?;
|
||||
Ok(self.repo.find(group_id, collection, &parsed).await?)
|
||||
}
|
||||
|
||||
async fn find_one(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
filter: serde_json::Value,
|
||||
) -> Result<Option<DocRow>, GroupDocsError> {
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
self.check_read(cx, group_id).await?;
|
||||
let mut parsed = parse_filter(&filter)?;
|
||||
if parsed.limit.is_none() {
|
||||
parsed.limit = Some(1);
|
||||
}
|
||||
let rows = self.repo.find(group_id, collection, &parsed).await?;
|
||||
Ok(rows.into_iter().next())
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
id: DocId,
|
||||
data: serde_json::Value,
|
||||
) -> Result<(), GroupDocsError> {
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
validate_data(&data)?;
|
||||
self.check_data_size(&data)?;
|
||||
self.check_write(cx, group_id).await?;
|
||||
match self.repo.update(group_id, collection, id, data).await? {
|
||||
Some(_) => Ok(()),
|
||||
None => Err(GroupDocsError::NotFound),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
id: DocId,
|
||||
) -> Result<bool, GroupDocsError> {
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
self.check_write(cx, group_id).await?;
|
||||
Ok(self.repo.delete(group_id, collection, id).await?.is_some())
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<DocsListPage, GroupDocsError> {
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
self.check_read(cx, group_id).await?;
|
||||
Ok(self.repo.list(group_id, collection, cursor, limit).await?)
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Tests — in-memory repo + a fake resolver so unit tests don't need Postgres.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::authz::{AuthzError, AuthzRepo};
|
||||
use crate::docs_filter::DocsFilter;
|
||||
use chrono::Utc;
|
||||
use picloud_shared::{
|
||||
AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, Principal, RequestId, ScriptId,
|
||||
UserId,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use tokio::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Default)]
|
||||
struct InMemoryGroupDocsRepo {
|
||||
// (group, collection) -> id -> data
|
||||
data: Mutex<HashMap<(GroupId, String), HashMap<Uuid, serde_json::Value>>>,
|
||||
}
|
||||
|
||||
fn row(id: Uuid, data: serde_json::Value) -> DocRow {
|
||||
DocRow {
|
||||
id,
|
||||
data,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GroupDocsRepo for InMemoryGroupDocsRepo {
|
||||
async fn create(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
data: serde_json::Value,
|
||||
) -> Result<DocRow, GroupDocsRepoError> {
|
||||
let id = Uuid::new_v4();
|
||||
self.data
|
||||
.lock()
|
||||
.await
|
||||
.entry((group_id, collection.to_string()))
|
||||
.or_default()
|
||||
.insert(id, data.clone());
|
||||
Ok(row(id, data))
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: DocId,
|
||||
) -> Result<Option<DocRow>, GroupDocsRepoError> {
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.get(&(group_id, collection.to_string()))
|
||||
.and_then(|m| m.get(&id).cloned())
|
||||
.map(|d| row(id, d)))
|
||||
}
|
||||
|
||||
// The fake ignores the filter (filter SQL is covered by docs_repo tests
|
||||
// + the journey); returns all docs in the collection.
|
||||
async fn find(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
_filter: &DocsFilter,
|
||||
) -> Result<Vec<DocRow>, GroupDocsRepoError> {
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.get(&(group_id, collection.to_string()))
|
||||
.map(|m| m.iter().map(|(id, d)| row(*id, d.clone())).collect())
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: DocId,
|
||||
data: serde_json::Value,
|
||||
) -> Result<Option<serde_json::Value>, GroupDocsRepoError> {
|
||||
let mut guard = self.data.lock().await;
|
||||
let coll = guard.entry((group_id, collection.to_string())).or_default();
|
||||
Ok(coll.insert(id, data))
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: DocId,
|
||||
) -> Result<Option<serde_json::Value>, GroupDocsRepoError> {
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.get_mut(&(group_id, collection.to_string()))
|
||||
.and_then(|m| m.remove(&id)))
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
_group_id: GroupId,
|
||||
_collection: &str,
|
||||
_cursor: Option<&str>,
|
||||
_limit: u32,
|
||||
) -> Result<DocsListPage, GroupDocsRepoError> {
|
||||
Ok(DocsListPage {
|
||||
docs: vec![],
|
||||
next_cursor: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeResolver {
|
||||
map: HashMap<(AppId, String), GroupId>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GroupCollectionResolver for FakeResolver {
|
||||
async fn resolve_owning_group(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
_kind: &str,
|
||||
) -> Result<Option<GroupId>, sqlx::Error> {
|
||||
Ok(self.map.get(&(app_id, name.to_lowercase())).copied())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DenyingAuthzRepo;
|
||||
|
||||
#[async_trait]
|
||||
impl AuthzRepo for DenyingAuthzRepo {
|
||||
async fn membership(
|
||||
&self,
|
||||
_user_id: UserId,
|
||||
_app_id: AppId,
|
||||
) -> Result<Option<AppRole>, AuthzError> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn cx_with(app_id: AppId, principal: Option<Principal>) -> SdkCallCx {
|
||||
SdkCallCx {
|
||||
app_id,
|
||||
script_id: ScriptId::new(),
|
||||
principal,
|
||||
execution_id: ExecutionId::new(),
|
||||
request_id: RequestId::new(),
|
||||
trigger_depth: 0,
|
||||
root_execution_id: ExecutionId::new(),
|
||||
is_dead_letter_handler: false,
|
||||
event: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn owner() -> Principal {
|
||||
Principal {
|
||||
user_id: AdminUserId::new(),
|
||||
instance_role: InstanceRole::Owner,
|
||||
scopes: None,
|
||||
app_binding: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn svc(resolver: FakeResolver) -> GroupDocsServiceImpl {
|
||||
GroupDocsServiceImpl::new(
|
||||
Arc::new(InMemoryGroupDocsRepo::default()),
|
||||
Arc::new(resolver),
|
||||
Arc::new(DenyingAuthzRepo),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unrelated_app_gets_collection_not_shared() {
|
||||
let app_a = AppId::new();
|
||||
let app_b = AppId::new();
|
||||
let group = GroupId::new();
|
||||
let mut resolver = FakeResolver::default();
|
||||
resolver.map.insert((app_a, "articles".into()), group);
|
||||
let docs = svc(resolver);
|
||||
|
||||
let cx_a = cx_with(app_a, Some(owner()));
|
||||
let id = docs
|
||||
.create(&cx_a, "articles", serde_json::json!({"t": "hi"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(docs.get(&cx_a, "articles", id).await.unwrap().is_some());
|
||||
|
||||
let cx_b = cx_with(app_b, Some(owner()));
|
||||
let err = docs
|
||||
.find(&cx_b, "articles", serde_json::json!({}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, GroupDocsError::CollectionNotShared(c) if c == "articles"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reads_open_writes_require_auth() {
|
||||
let app = AppId::new();
|
||||
let group = GroupId::new();
|
||||
let mut resolver = FakeResolver::default();
|
||||
resolver.map.insert((app, "articles".into()), group);
|
||||
let docs = svc(resolver);
|
||||
|
||||
let owner_cx = cx_with(app, Some(owner()));
|
||||
docs.create(&owner_cx, "articles", serde_json::json!({"t": "hi"}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Anonymous READ (find) is allowed.
|
||||
let anon = cx_with(app, None);
|
||||
let hits = docs
|
||||
.find(&anon, "articles", serde_json::json!({}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(hits.len(), 1);
|
||||
|
||||
// Anonymous WRITE (create) fails closed.
|
||||
let err = docs
|
||||
.create(&anon, "articles", serde_json::json!({"t": "x"}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, GroupDocsError::Forbidden));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_object_data_rejected() {
|
||||
let app = AppId::new();
|
||||
let group = GroupId::new();
|
||||
let mut resolver = FakeResolver::default();
|
||||
resolver.map.insert((app, "articles".into()), group);
|
||||
let docs = svc(resolver);
|
||||
let cx = cx_with(app, Some(owner()));
|
||||
let err = docs
|
||||
.create(&cx, "articles", serde_json::json!("not-an-object"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, GroupDocsError::InvalidData));
|
||||
}
|
||||
}
|
||||
399
crates/manager-core/src/group_files_repo.rs
Normal file
399
crates/manager-core/src/group_files_repo.rs
Normal file
@@ -0,0 +1,399 @@
|
||||
//! Low-level metadata (Postgres `group_files`) + blob bytes (filesystem) storage
|
||||
//! for §11.6 group-shared FILES collections. A near-clone of [`crate::files_repo`]
|
||||
//! keyed by the owning `group_id` instead of `app_id`.
|
||||
//!
|
||||
//! The security-sensitive disk mechanics — the atomic write+checksum protocol and
|
||||
//! checksum-on-read — are **not** duplicated: they come from the owner-relative
|
||||
//! free functions in [`crate::files_repo`] (`write_atomic_at` / `read_verify_at` /
|
||||
//! `final_path_at`), called with this repo's owner sub-path. Group blobs shard at
|
||||
//! `<root>/files/groups/<group_id>/<collection>/<id[0:2]>/<id>` — a `groups/` infix
|
||||
//! disjoint from the per-app `files/<app_id>/...` subtree, so the orphan sweeper
|
||||
//! ([crate::files_sweep]) covers both with one walk. Authorization, group
|
||||
//! resolution, value validation, and content-type sanitization live one layer up
|
||||
//! in `GroupFilesServiceImpl`.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{FileMeta, FileUpdate, FilesListPage, GroupId, NewFile};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::files_repo::{
|
||||
decode_cursor, encode_cursor, final_path_at, read_verify_at, write_atomic_at, FileUpdated,
|
||||
FilesRepoError,
|
||||
};
|
||||
|
||||
const FILES_LIST_MAX_LIMIT: u32 = 1_000;
|
||||
const FILES_LIST_DEFAULT_LIMIT: u32 = 100;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GroupFilesRepoError {
|
||||
#[error("database error: {0}")]
|
||||
Db(#[from] sqlx::Error),
|
||||
|
||||
#[error("filesystem error: {0}")]
|
||||
Io(String),
|
||||
|
||||
#[error("invalid collection name: {0}")]
|
||||
InvalidCollection(String),
|
||||
|
||||
/// Bytes on disk no longer match the stored checksum (or are missing).
|
||||
#[error("file content corrupted (checksum mismatch)")]
|
||||
Corrupted,
|
||||
|
||||
#[error("invalid pagination cursor")]
|
||||
InvalidCursor,
|
||||
}
|
||||
|
||||
impl From<FilesRepoError> for GroupFilesRepoError {
|
||||
fn from(e: FilesRepoError) -> Self {
|
||||
match e {
|
||||
FilesRepoError::Db(e) => Self::Db(e),
|
||||
FilesRepoError::Io(s) => Self::Io(s),
|
||||
FilesRepoError::InvalidCollection(c) => Self::InvalidCollection(c),
|
||||
FilesRepoError::Corrupted => Self::Corrupted,
|
||||
FilesRepoError::InvalidCursor => Self::InvalidCursor,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The owner-relative subdirectory of a **group**'s shared blobs under
|
||||
/// `<root>/files/`: `groups/<group_id>`. A UUID app-dir can never equal the
|
||||
/// literal `groups`, so app and group blob trees can't collide.
|
||||
pub(crate) fn group_owner_dir(group_id: GroupId) -> PathBuf {
|
||||
Path::new("groups").join(group_id.into_inner().to_string())
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait GroupFilesRepo: Send + Sync {
|
||||
async fn create(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
new: NewFile,
|
||||
) -> Result<FileMeta, GroupFilesRepoError>;
|
||||
|
||||
async fn head(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
) -> Result<Option<FileMeta>, GroupFilesRepoError>;
|
||||
|
||||
/// Reads + checksum-verifies the bytes. `Ok(None)` when no row exists;
|
||||
/// `Err(Corrupted)` when the row exists but the bytes are missing/mismatched.
|
||||
async fn get(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
) -> Result<Option<Vec<u8>>, GroupFilesRepoError>;
|
||||
|
||||
/// `Ok(None)` when no row exists (the service maps that to `NotFound`).
|
||||
async fn update(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
upd: FileUpdate,
|
||||
) -> Result<Option<FileUpdated>, GroupFilesRepoError>;
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
) -> Result<Option<FileMeta>, GroupFilesRepoError>;
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<FilesListPage, GroupFilesRepoError>;
|
||||
}
|
||||
|
||||
/// Filesystem-bytes + Postgres-metadata repo for group-shared files.
|
||||
pub struct FsGroupFilesRepo {
|
||||
pool: PgPool,
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl FsGroupFilesRepo {
|
||||
#[must_use]
|
||||
pub fn new(pool: PgPool, root: PathBuf) -> Self {
|
||||
Self { pool, root }
|
||||
}
|
||||
|
||||
/// Belt-and-suspenders path guard (the service validates at the SDK
|
||||
/// boundary). Mirrors `FsFilesRepo::guard_collection`.
|
||||
fn guard_collection(collection: &str) -> Result<(), GroupFilesRepoError> {
|
||||
if collection.is_empty()
|
||||
|| collection.contains('/')
|
||||
|| collection.contains('\\')
|
||||
|| collection.contains("..")
|
||||
|| collection.contains('\0')
|
||||
{
|
||||
return Err(GroupFilesRepoError::InvalidCollection(
|
||||
collection.to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_atomic(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
bytes: &[u8],
|
||||
) -> Result<String, GroupFilesRepoError> {
|
||||
Ok(write_atomic_at(
|
||||
&self.root,
|
||||
&group_owner_dir(group_id),
|
||||
collection,
|
||||
id,
|
||||
bytes,
|
||||
)?)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GroupFilesRepo for FsGroupFilesRepo {
|
||||
async fn create(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
new: NewFile,
|
||||
) -> Result<FileMeta, GroupFilesRepoError> {
|
||||
Self::guard_collection(collection)?;
|
||||
let id = Uuid::new_v4();
|
||||
let size = i64::try_from(new.data.len()).unwrap_or(i64::MAX);
|
||||
let checksum = self.write_atomic(group_id, collection, id, &new.data)?;
|
||||
|
||||
let row: GroupFileRow = sqlx::query_as(
|
||||
"INSERT INTO group_files \
|
||||
(group_id, collection, id, name, content_type, size_bytes, checksum_sha256) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) \
|
||||
RETURNING id, collection, name, content_type, size_bytes, \
|
||||
checksum_sha256, created_at, updated_at",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(id)
|
||||
.bind(&new.name)
|
||||
.bind(&new.content_type)
|
||||
.bind(size)
|
||||
.bind(&checksum)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(row.into_meta())
|
||||
}
|
||||
|
||||
async fn head(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
) -> Result<Option<FileMeta>, GroupFilesRepoError> {
|
||||
Self::guard_collection(collection)?;
|
||||
let row: Option<GroupFileRow> = sqlx::query_as(
|
||||
"SELECT id, collection, name, content_type, size_bytes, \
|
||||
checksum_sha256, created_at, updated_at \
|
||||
FROM group_files WHERE group_id = $1 AND collection = $2 AND id = $3",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(GroupFileRow::into_meta))
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
) -> Result<Option<Vec<u8>>, GroupFilesRepoError> {
|
||||
Self::guard_collection(collection)?;
|
||||
let row: Option<(String,)> = sqlx::query_as(
|
||||
"SELECT checksum_sha256 FROM group_files \
|
||||
WHERE group_id = $1 AND collection = $2 AND id = $3",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
let Some((stored_checksum,)) = row else {
|
||||
return Ok(None);
|
||||
};
|
||||
let bytes = read_verify_at(
|
||||
&self.root,
|
||||
&group_owner_dir(group_id),
|
||||
collection,
|
||||
id,
|
||||
&stored_checksum,
|
||||
)?;
|
||||
Ok(Some(bytes))
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
upd: FileUpdate,
|
||||
) -> Result<Option<FileUpdated>, GroupFilesRepoError> {
|
||||
Self::guard_collection(collection)?;
|
||||
let Some(prev) = self.head(group_id, collection, id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let size = i64::try_from(upd.data.len()).unwrap_or(i64::MAX);
|
||||
let checksum = self.write_atomic(group_id, collection, id, &upd.data)?;
|
||||
|
||||
let row: GroupFileRow = sqlx::query_as(
|
||||
"UPDATE group_files SET \
|
||||
name = COALESCE($4, name), \
|
||||
content_type = COALESCE($5, content_type), \
|
||||
size_bytes = $6, \
|
||||
checksum_sha256 = $7, \
|
||||
updated_at = NOW() \
|
||||
WHERE group_id = $1 AND collection = $2 AND id = $3 \
|
||||
RETURNING id, collection, name, content_type, size_bytes, \
|
||||
checksum_sha256, created_at, updated_at",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(id)
|
||||
.bind(upd.name.as_deref())
|
||||
.bind(upd.content_type.as_deref())
|
||||
.bind(size)
|
||||
.bind(&checksum)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(Some(FileUpdated {
|
||||
new: row.into_meta(),
|
||||
prev,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
) -> Result<Option<FileMeta>, GroupFilesRepoError> {
|
||||
Self::guard_collection(collection)?;
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let row: Option<GroupFileRow> = sqlx::query_as(
|
||||
"SELECT id, collection, name, content_type, size_bytes, \
|
||||
checksum_sha256, created_at, updated_at \
|
||||
FROM group_files WHERE group_id = $1 AND collection = $2 AND id = $3 \
|
||||
FOR UPDATE",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let Some(row) = row else {
|
||||
tx.rollback().await?;
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
sqlx::query("DELETE FROM group_files WHERE group_id = $1 AND collection = $2 AND id = $3")
|
||||
.bind(group_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
// Row gone; unlink the bytes. A failure here leaves an orphan file
|
||||
// (reclaimed by the sweep) — not fatal.
|
||||
let path = final_path_at(&self.root, &group_owner_dir(group_id), collection, id);
|
||||
if let Err(e) = std::fs::remove_file(&path) {
|
||||
if e.kind() != std::io::ErrorKind::NotFound {
|
||||
tracing::warn!(path = %path.display(), error = %e, "group files: unlink after delete failed (orphan)");
|
||||
}
|
||||
}
|
||||
Ok(Some(row.into_meta()))
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<FilesListPage, GroupFilesRepoError> {
|
||||
Self::guard_collection(collection)?;
|
||||
let limit = if limit == 0 {
|
||||
FILES_LIST_DEFAULT_LIMIT
|
||||
} else {
|
||||
limit.min(FILES_LIST_MAX_LIMIT)
|
||||
};
|
||||
let last_id = match cursor {
|
||||
Some(c) => Some(decode_cursor(c)?),
|
||||
None => None,
|
||||
};
|
||||
let take = i64::from(limit) + 1;
|
||||
let rows: Vec<GroupFileRow> = sqlx::query_as(
|
||||
"SELECT id, collection, name, content_type, size_bytes, \
|
||||
checksum_sha256, created_at, updated_at \
|
||||
FROM group_files \
|
||||
WHERE group_id = $1 AND collection = $2 \
|
||||
AND ($3::uuid IS NULL OR id > $3) \
|
||||
ORDER BY id ASC \
|
||||
LIMIT $4",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(last_id)
|
||||
.bind(take)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
let mut files: Vec<FileMeta> = rows.into_iter().map(GroupFileRow::into_meta).collect();
|
||||
let next_cursor = if files.len() > limit as usize {
|
||||
files.truncate(limit as usize);
|
||||
files.last().map(|m| encode_cursor(m.id))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(FilesListPage { files, next_cursor })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct GroupFileRow {
|
||||
id: Uuid,
|
||||
collection: String,
|
||||
name: String,
|
||||
content_type: String,
|
||||
size_bytes: i64,
|
||||
checksum_sha256: String,
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl GroupFileRow {
|
||||
fn into_meta(self) -> FileMeta {
|
||||
FileMeta {
|
||||
id: self.id,
|
||||
collection: self.collection,
|
||||
name: self.name,
|
||||
content_type: self.content_type,
|
||||
size: u64::try_from(self.size_bytes).unwrap_or(0),
|
||||
checksum: self.checksum_sha256,
|
||||
created_at: self.created_at,
|
||||
updated_at: self.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
492
crates/manager-core/src/group_files_service.rs
Normal file
492
crates/manager-core/src/group_files_service.rs
Normal file
@@ -0,0 +1,492 @@
|
||||
//! `GroupFilesServiceImpl` — wires `GroupFilesRepo` + the group-collection
|
||||
//! registry underneath the `picloud_shared::GroupFilesService` trait that scripts
|
||||
//! reach via the `files::shared_collection("name")` Rhai handle (§11.6).
|
||||
//!
|
||||
//! Combines the group-KV/docs service pattern (owner resolution from `cx.app_id`,
|
||||
//! reads-open / writes-authed authz) with the files surface (collection
|
||||
//! path-validation, field + size-cap validation, content-type sanitization). No
|
||||
//! event emission in the MVP (the "group trigger has no app to watch" deferral).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{
|
||||
sanitize_stored_content_type, validate_files_collection, FileMeta, FileUpdate, FilesListPage,
|
||||
GroupFilesError, GroupFilesService, GroupId, NewFile, SdkCallCx,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::authz::{self, AuthzRepo, Capability};
|
||||
use crate::files_repo::FileUpdated;
|
||||
use crate::group_collection_repo::GroupCollectionResolver;
|
||||
use crate::group_files_repo::{GroupFilesRepo, GroupFilesRepoError};
|
||||
|
||||
/// The registry `kind` this service resolves.
|
||||
const KIND_FILES: &str = "files";
|
||||
|
||||
pub struct GroupFilesServiceImpl {
|
||||
repo: Arc<dyn GroupFilesRepo>,
|
||||
resolver: Arc<dyn GroupCollectionResolver>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
max_file_size_bytes: usize,
|
||||
}
|
||||
|
||||
impl GroupFilesServiceImpl {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
repo: Arc<dyn GroupFilesRepo>,
|
||||
resolver: Arc<dyn GroupCollectionResolver>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
max_file_size_bytes: usize,
|
||||
) -> Self {
|
||||
Self {
|
||||
repo,
|
||||
resolver,
|
||||
authz,
|
||||
max_file_size_bytes,
|
||||
}
|
||||
}
|
||||
|
||||
/// The structural boundary: resolve the collection to its owning group
|
||||
/// (kind=`files`) on the calling app's chain. `CollectionNotShared` when no
|
||||
/// ancestor group declares it.
|
||||
async fn owning_group(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
) -> Result<GroupId, GroupFilesError> {
|
||||
self.resolver
|
||||
.resolve_owning_group(cx.app_id, collection, KIND_FILES)
|
||||
.await
|
||||
.map_err(|e| GroupFilesError::Backend(e.to_string()))?
|
||||
.ok_or_else(|| GroupFilesError::CollectionNotShared(collection.to_string()))
|
||||
}
|
||||
|
||||
async fn check_read(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupFilesError> {
|
||||
authz::script_gate(
|
||||
&*self.authz,
|
||||
cx,
|
||||
Capability::GroupFilesRead(group_id),
|
||||
|| GroupFilesError::Forbidden,
|
||||
GroupFilesError::Backend,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn check_write(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupFilesError> {
|
||||
// Fails closed for an anonymous principal — shared mutation always needs
|
||||
// an authenticated editor+ on the owning group.
|
||||
authz::script_gate_require_principal(
|
||||
&*self.authz,
|
||||
cx,
|
||||
Capability::GroupFilesWrite(group_id),
|
||||
|| GroupFilesError::Forbidden,
|
||||
GroupFilesError::Backend,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
/// Invalid UUIDs aren't an error shape the SDK exposes — for reads/deletes they
|
||||
/// simply mean "no such file" (mirrors `files_service::parse_id`).
|
||||
fn parse_id(id: &str) -> Option<Uuid> {
|
||||
Uuid::parse_str(id).ok()
|
||||
}
|
||||
|
||||
impl From<GroupFilesRepoError> for GroupFilesError {
|
||||
fn from(e: GroupFilesRepoError) -> Self {
|
||||
match e {
|
||||
GroupFilesRepoError::Corrupted => Self::Corrupted,
|
||||
GroupFilesRepoError::InvalidCollection(c) => Self::InvalidCollection(c),
|
||||
other => Self::Backend(other.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GroupFilesService for GroupFilesServiceImpl {
|
||||
async fn create(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
mut new: NewFile,
|
||||
) -> Result<Uuid, GroupFilesError> {
|
||||
validate_files_collection(collection)?;
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
new.validate(self.max_file_size_bytes)?;
|
||||
// Coerce dangerous render types to application/octet-stream after the
|
||||
// shape checks pass (same as app files, audit 2026-06-11 C-2).
|
||||
new.content_type = sanitize_stored_content_type(&new.content_type);
|
||||
self.check_write(cx, group_id).await?;
|
||||
Ok(self.repo.create(group_id, collection, new).await?.id)
|
||||
}
|
||||
|
||||
async fn head(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
id: &str,
|
||||
) -> Result<Option<FileMeta>, GroupFilesError> {
|
||||
validate_files_collection(collection)?;
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
self.check_read(cx, group_id).await?;
|
||||
let Some(uuid) = parse_id(id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(self.repo.head(group_id, collection, uuid).await?)
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
id: &str,
|
||||
) -> Result<Option<Vec<u8>>, GroupFilesError> {
|
||||
validate_files_collection(collection)?;
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
self.check_read(cx, group_id).await?;
|
||||
let Some(uuid) = parse_id(id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(self.repo.get(group_id, collection, uuid).await?)
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
id: &str,
|
||||
mut upd: FileUpdate,
|
||||
) -> Result<(), GroupFilesError> {
|
||||
validate_files_collection(collection)?;
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
upd.validate(self.max_file_size_bytes)?;
|
||||
if let Some(ct) = upd.content_type.as_deref() {
|
||||
upd.content_type = Some(sanitize_stored_content_type(ct));
|
||||
}
|
||||
self.check_write(cx, group_id).await?;
|
||||
let Some(uuid) = parse_id(id) else {
|
||||
return Err(GroupFilesError::NotFound);
|
||||
};
|
||||
match self.repo.update(group_id, collection, uuid, upd).await? {
|
||||
Some(FileUpdated { .. }) => Ok(()),
|
||||
None => Err(GroupFilesError::NotFound),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
id: &str,
|
||||
) -> Result<bool, GroupFilesError> {
|
||||
validate_files_collection(collection)?;
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
self.check_write(cx, group_id).await?;
|
||||
let Some(uuid) = parse_id(id) else {
|
||||
return Ok(false);
|
||||
};
|
||||
Ok(self
|
||||
.repo
|
||||
.delete(group_id, collection, uuid)
|
||||
.await?
|
||||
.is_some())
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<FilesListPage, GroupFilesError> {
|
||||
validate_files_collection(collection)?;
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
self.check_read(cx, group_id).await?;
|
||||
Ok(self.repo.list(group_id, collection, cursor, limit).await?)
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Tests — in-memory repo + a fake resolver so unit tests don't need Postgres or
|
||||
// a filesystem. The on-disk atomic-write/checksum mechanics are covered by the
|
||||
// tempdir tests in `files_repo.rs`.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::authz::{AuthzError, AuthzRepo};
|
||||
use crate::group_files_repo::GroupFilesRepoError;
|
||||
use chrono::Utc;
|
||||
use picloud_shared::{
|
||||
AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, Principal, RequestId, ScriptId,
|
||||
UserId,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[derive(Default)]
|
||||
struct InMemoryGroupFilesRepo {
|
||||
#[allow(clippy::type_complexity)]
|
||||
data: Mutex<HashMap<(GroupId, String, Uuid), (FileMeta, Vec<u8>)>>,
|
||||
}
|
||||
|
||||
fn meta(id: Uuid, collection: &str, new: &NewFile) -> FileMeta {
|
||||
FileMeta {
|
||||
id,
|
||||
collection: collection.to_string(),
|
||||
name: new.name.clone(),
|
||||
content_type: new.content_type.clone(),
|
||||
size: new.data.len() as u64,
|
||||
checksum: String::new(),
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GroupFilesRepo for InMemoryGroupFilesRepo {
|
||||
async fn create(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
new: NewFile,
|
||||
) -> Result<FileMeta, GroupFilesRepoError> {
|
||||
let id = Uuid::new_v4();
|
||||
let m = meta(id, collection, &new);
|
||||
self.data.lock().await.insert(
|
||||
(group_id, collection.to_string(), id),
|
||||
(m.clone(), new.data),
|
||||
);
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
async fn head(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
) -> Result<Option<FileMeta>, GroupFilesRepoError> {
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.get(&(group_id, collection.to_string(), id))
|
||||
.map(|(m, _)| m.clone()))
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
) -> Result<Option<Vec<u8>>, GroupFilesRepoError> {
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.get(&(group_id, collection.to_string(), id))
|
||||
.map(|(_, b)| b.clone()))
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
upd: FileUpdate,
|
||||
) -> Result<Option<FileUpdated>, GroupFilesRepoError> {
|
||||
let mut data = self.data.lock().await;
|
||||
let key = (group_id, collection.to_string(), id);
|
||||
let Some((prev, _)) = data.get(&key).cloned() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut m = prev.clone();
|
||||
m.size = upd.data.len() as u64;
|
||||
if let Some(n) = &upd.name {
|
||||
m.name = n.clone();
|
||||
}
|
||||
data.insert(key, (m.clone(), upd.data));
|
||||
Ok(Some(FileUpdated { new: m, prev }))
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
) -> Result<Option<FileMeta>, GroupFilesRepoError> {
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.remove(&(group_id, collection.to_string(), id))
|
||||
.map(|(m, _)| m))
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
_cursor: Option<&str>,
|
||||
_limit: u32,
|
||||
) -> Result<FilesListPage, GroupFilesRepoError> {
|
||||
let files = self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.iter()
|
||||
.filter(|((g, c, _), _)| *g == group_id && c == collection)
|
||||
.map(|(_, (m, _))| m.clone())
|
||||
.collect();
|
||||
Ok(FilesListPage {
|
||||
files,
|
||||
next_cursor: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeResolver {
|
||||
map: HashMap<(AppId, String), GroupId>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GroupCollectionResolver for FakeResolver {
|
||||
async fn resolve_owning_group(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
_kind: &str,
|
||||
) -> Result<Option<GroupId>, sqlx::Error> {
|
||||
Ok(self.map.get(&(app_id, name.to_lowercase())).copied())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DenyingAuthzRepo;
|
||||
|
||||
#[async_trait]
|
||||
impl AuthzRepo for DenyingAuthzRepo {
|
||||
async fn membership(
|
||||
&self,
|
||||
_user_id: UserId,
|
||||
_app_id: AppId,
|
||||
) -> Result<Option<AppRole>, AuthzError> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn cx_with(app_id: AppId, principal: Option<Principal>) -> SdkCallCx {
|
||||
SdkCallCx {
|
||||
app_id,
|
||||
script_id: ScriptId::new(),
|
||||
principal,
|
||||
execution_id: ExecutionId::new(),
|
||||
request_id: RequestId::new(),
|
||||
trigger_depth: 0,
|
||||
root_execution_id: ExecutionId::new(),
|
||||
is_dead_letter_handler: false,
|
||||
event: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn owner() -> Principal {
|
||||
Principal {
|
||||
user_id: AdminUserId::new(),
|
||||
instance_role: InstanceRole::Owner,
|
||||
scopes: None,
|
||||
app_binding: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn svc(resolver: FakeResolver) -> GroupFilesServiceImpl {
|
||||
GroupFilesServiceImpl::new(
|
||||
Arc::new(InMemoryGroupFilesRepo::default()),
|
||||
Arc::new(resolver),
|
||||
Arc::new(DenyingAuthzRepo),
|
||||
10 * 1024 * 1024,
|
||||
)
|
||||
}
|
||||
|
||||
fn new_file(name: &str, data: &[u8]) -> NewFile {
|
||||
NewFile {
|
||||
name: name.to_string(),
|
||||
content_type: "application/octet-stream".to_string(),
|
||||
data: data.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unrelated_app_gets_collection_not_shared() {
|
||||
let app_a = AppId::new();
|
||||
let app_b = AppId::new();
|
||||
let group = GroupId::new();
|
||||
let mut resolver = FakeResolver::default();
|
||||
resolver.map.insert((app_a, "assets".into()), group);
|
||||
let files = svc(resolver);
|
||||
|
||||
let cx_a = cx_with(app_a, Some(owner()));
|
||||
let id = files
|
||||
.create(&cx_a, "assets", new_file("a.txt", b"hi"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(files
|
||||
.get(&cx_a, "assets", &id.to_string())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some());
|
||||
|
||||
let cx_b = cx_with(app_b, Some(owner()));
|
||||
let err = files.list(&cx_b, "assets", None, 100).await.unwrap_err();
|
||||
assert!(matches!(err, GroupFilesError::CollectionNotShared(c) if c == "assets"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reads_open_writes_require_auth() {
|
||||
let app = AppId::new();
|
||||
let group = GroupId::new();
|
||||
let mut resolver = FakeResolver::default();
|
||||
resolver.map.insert((app, "assets".into()), group);
|
||||
let files = svc(resolver);
|
||||
|
||||
let owner_cx = cx_with(app, Some(owner()));
|
||||
let id = files
|
||||
.create(&owner_cx, "assets", new_file("a.txt", b"hi"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Anonymous READ (get) is allowed.
|
||||
let anon = cx_with(app, None);
|
||||
let bytes = files.get(&anon, "assets", &id.to_string()).await.unwrap();
|
||||
assert_eq!(bytes, Some(b"hi".to_vec()));
|
||||
|
||||
// Anonymous WRITE (create) fails closed.
|
||||
let err = files
|
||||
.create(&anon, "assets", new_file("x.txt", b"x"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, GroupFilesError::Forbidden));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oversize_blob_rejected() {
|
||||
let app = AppId::new();
|
||||
let group = GroupId::new();
|
||||
let mut resolver = FakeResolver::default();
|
||||
resolver.map.insert((app, "assets".into()), group);
|
||||
let files = GroupFilesServiceImpl::new(
|
||||
Arc::new(InMemoryGroupFilesRepo::default()),
|
||||
Arc::new(resolver),
|
||||
Arc::new(DenyingAuthzRepo),
|
||||
8, // tiny cap
|
||||
);
|
||||
let cx = cx_with(app, Some(owner()));
|
||||
let err = files
|
||||
.create(&cx, "assets", new_file("big", b"123456789"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, GroupFilesError::TooLarge { limit: 8, .. }));
|
||||
}
|
||||
}
|
||||
222
crates/manager-core/src/group_kv_repo.rs
Normal file
222
crates/manager-core/src/group_kv_repo.rs
Normal file
@@ -0,0 +1,222 @@
|
||||
//! Low-level Postgres CRUD over `group_kv_entries` (§11.6). A near-clone of
|
||||
//! [`crate::kv_repo`] keyed by the owning `group_id` instead of `app_id` —
|
||||
//! authorization, group resolution, event policy, and empty-collection
|
||||
//! validation live one layer up in `GroupKvServiceImpl`.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine as _;
|
||||
use picloud_shared::{GroupId, KvListPage};
|
||||
use sqlx::PgPool;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GroupKvRepoError {
|
||||
#[error("database error: {0}")]
|
||||
Db(#[from] sqlx::Error),
|
||||
|
||||
#[error("invalid pagination cursor")]
|
||||
InvalidCursor,
|
||||
}
|
||||
|
||||
/// Repo surface. The trait is exposed so tests can substitute an in-memory
|
||||
/// backing without spinning up Postgres.
|
||||
#[async_trait]
|
||||
pub trait GroupKvRepo: Send + Sync {
|
||||
async fn get(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
) -> Result<Option<serde_json::Value>, GroupKvRepoError>;
|
||||
|
||||
/// Upserts the row. Returns the previous value (if any) so callers can
|
||||
/// determine whether this was an `insert` or an `update`.
|
||||
async fn set(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
value: serde_json::Value,
|
||||
) -> Result<Option<serde_json::Value>, GroupKvRepoError>;
|
||||
|
||||
/// Returns the deleted value if present, `None` if the row didn't exist.
|
||||
async fn delete(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
) -> Result<Option<serde_json::Value>, GroupKvRepoError>;
|
||||
|
||||
async fn has(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
) -> Result<bool, GroupKvRepoError>;
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<KvListPage, GroupKvRepoError>;
|
||||
}
|
||||
|
||||
pub struct PostgresGroupKvRepo {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresGroupKvRepo {
|
||||
#[must_use]
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
/// Hard ceiling on `list` page size (mirrors `kv_repo`).
|
||||
const KV_LIST_MAX_LIMIT: u32 = 1_000;
|
||||
const KV_LIST_DEFAULT_LIMIT: u32 = 100;
|
||||
|
||||
#[async_trait]
|
||||
impl GroupKvRepo for PostgresGroupKvRepo {
|
||||
async fn get(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
) -> Result<Option<serde_json::Value>, GroupKvRepoError> {
|
||||
let row: Option<(serde_json::Value,)> = sqlx::query_as(
|
||||
"SELECT value FROM group_kv_entries \
|
||||
WHERE group_id = $1 AND collection = $2 AND key = $3",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(key)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(|(v,)| v))
|
||||
}
|
||||
|
||||
async fn set(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
value: serde_json::Value,
|
||||
) -> Result<Option<serde_json::Value>, GroupKvRepoError> {
|
||||
let row: Option<(Option<serde_json::Value>,)> = sqlx::query_as(
|
||||
"WITH prev AS (\
|
||||
SELECT value FROM group_kv_entries \
|
||||
WHERE group_id = $1 AND collection = $2 AND key = $3\
|
||||
), \
|
||||
upserted AS (\
|
||||
INSERT INTO group_kv_entries (group_id, collection, key, value) \
|
||||
VALUES ($1, $2, $3, $4) \
|
||||
ON CONFLICT (group_id, collection, key) DO UPDATE \
|
||||
SET value = EXCLUDED.value, updated_at = NOW() \
|
||||
RETURNING 1\
|
||||
) \
|
||||
SELECT (SELECT value FROM prev) FROM upserted",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(key)
|
||||
.bind(value)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.and_then(|(v,)| v))
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
) -> Result<Option<serde_json::Value>, GroupKvRepoError> {
|
||||
let row: Option<(serde_json::Value,)> = sqlx::query_as(
|
||||
"DELETE FROM group_kv_entries \
|
||||
WHERE group_id = $1 AND collection = $2 AND key = $3 \
|
||||
RETURNING value",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(key)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(|(v,)| v))
|
||||
}
|
||||
|
||||
async fn has(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
) -> Result<bool, GroupKvRepoError> {
|
||||
let row: Option<(i64,)> = sqlx::query_as(
|
||||
"SELECT 1 FROM group_kv_entries \
|
||||
WHERE group_id = $1 AND collection = $2 AND key = $3",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(key)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.is_some())
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<KvListPage, GroupKvRepoError> {
|
||||
let limit = if limit == 0 {
|
||||
KV_LIST_DEFAULT_LIMIT
|
||||
} else {
|
||||
limit.min(KV_LIST_MAX_LIMIT)
|
||||
};
|
||||
|
||||
let last_key = match cursor {
|
||||
Some(c) => Some(decode_cursor(c)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let take = i64::from(limit) + 1;
|
||||
let rows: Vec<(String,)> = sqlx::query_as(
|
||||
"SELECT key FROM group_kv_entries \
|
||||
WHERE group_id = $1 AND collection = $2 \
|
||||
AND ($3::text IS NULL OR key > $3) \
|
||||
ORDER BY key ASC \
|
||||
LIMIT $4",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(last_key.as_deref())
|
||||
.bind(take)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
let mut keys: Vec<String> = rows.into_iter().map(|(k,)| k).collect();
|
||||
let next_cursor = if keys.len() > limit as usize {
|
||||
keys.truncate(limit as usize);
|
||||
keys.last().map(|k| encode_cursor(k))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(KvListPage { keys, next_cursor })
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_cursor(last_key: &str) -> String {
|
||||
URL_SAFE_NO_PAD.encode(last_key.as_bytes())
|
||||
}
|
||||
|
||||
fn decode_cursor(cursor: &str) -> Result<String, GroupKvRepoError> {
|
||||
let bytes = URL_SAFE_NO_PAD
|
||||
.decode(cursor)
|
||||
.map_err(|_| GroupKvRepoError::InvalidCursor)?;
|
||||
String::from_utf8(bytes).map_err(|_| GroupKvRepoError::InvalidCursor)
|
||||
}
|
||||
419
crates/manager-core/src/group_kv_service.rs
Normal file
419
crates/manager-core/src/group_kv_service.rs
Normal file
@@ -0,0 +1,419 @@
|
||||
//! `GroupKvServiceImpl` — wires `GroupKvRepo` + the group-collection registry
|
||||
//! underneath the `picloud_shared::GroupKvService` trait that scripts reach via
|
||||
//! the `kv::shared_collection("name")` Rhai handle (§11.6).
|
||||
//!
|
||||
//! Layers added over the raw repo:
|
||||
//!
|
||||
//! 1. Empty-collection rejection.
|
||||
//! 2. **Owner resolution** (the structural isolation boundary): the collection
|
||||
//! name is resolved to the nearest ancestor group that declares it shared,
|
||||
//! walking the calling app's chain. No declaration on the chain → a clean
|
||||
//! `CollectionNotShared`, BEFORE any authz or storage access.
|
||||
//! 3. **Reads-open / writes-authed authz**: reads use `script_gate`
|
||||
//! (anonymous public scripts skip); writes use `script_gate_require_principal`
|
||||
//! (anonymous fails closed — shared mutation always needs an authenticated
|
||||
//! editor+ on the owning group).
|
||||
//! 4. Per-value size cap (reuses `PICLOUD_KV_MAX_VALUE_BYTES`).
|
||||
//!
|
||||
//! No event emission in the MVP: a write to a shared collection has no single
|
||||
//! app to attribute a trigger to (the "group trigger has no app to watch"
|
||||
//! problem). Documented as a deferral in docs §11.6.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{GroupId, GroupKvError, GroupKvService, KvListPage, SdkCallCx};
|
||||
|
||||
use crate::authz::{self, AuthzRepo, Capability};
|
||||
use crate::group_collection_repo::GroupCollectionResolver;
|
||||
use crate::group_kv_repo::{GroupKvRepo, GroupKvRepoError};
|
||||
use crate::kv_service::kv_max_value_bytes_from_env;
|
||||
|
||||
/// The registry `kind` this service resolves.
|
||||
const KIND_KV: &str = "kv";
|
||||
|
||||
pub struct GroupKvServiceImpl {
|
||||
repo: Arc<dyn GroupKvRepo>,
|
||||
resolver: Arc<dyn GroupCollectionResolver>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
max_value_bytes: usize,
|
||||
}
|
||||
|
||||
impl GroupKvServiceImpl {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
repo: Arc<dyn GroupKvRepo>,
|
||||
resolver: Arc<dyn GroupCollectionResolver>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
) -> Self {
|
||||
Self::with_max_value_bytes(repo, resolver, authz, kv_max_value_bytes_from_env())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_max_value_bytes(
|
||||
repo: Arc<dyn GroupKvRepo>,
|
||||
resolver: Arc<dyn GroupCollectionResolver>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
max_value_bytes: usize,
|
||||
) -> Self {
|
||||
Self {
|
||||
repo,
|
||||
resolver,
|
||||
authz,
|
||||
max_value_bytes,
|
||||
}
|
||||
}
|
||||
|
||||
/// The structural boundary: resolve the collection to its owning group on
|
||||
/// the calling app's chain. `CollectionNotShared` when no ancestor group
|
||||
/// declares it — the same outcome a foreign app gets, by construction.
|
||||
async fn owning_group(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
) -> Result<GroupId, GroupKvError> {
|
||||
if collection.is_empty() {
|
||||
return Err(GroupKvError::InvalidCollection);
|
||||
}
|
||||
self.resolver
|
||||
.resolve_owning_group(cx.app_id, collection, KIND_KV)
|
||||
.await
|
||||
.map_err(|e| GroupKvError::Backend(e.to_string()))?
|
||||
.ok_or_else(|| GroupKvError::CollectionNotShared(collection.to_string()))
|
||||
}
|
||||
|
||||
async fn check_read(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupKvError> {
|
||||
authz::script_gate(
|
||||
&*self.authz,
|
||||
cx,
|
||||
Capability::GroupKvRead(group_id),
|
||||
|| GroupKvError::Forbidden,
|
||||
GroupKvError::Backend,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn check_write(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupKvError> {
|
||||
// Fails closed for an anonymous principal: shared mutation always needs
|
||||
// an authenticated editor+ on the owning group.
|
||||
authz::script_gate_require_principal(
|
||||
&*self.authz,
|
||||
cx,
|
||||
Capability::GroupKvWrite(group_id),
|
||||
|| GroupKvError::Forbidden,
|
||||
GroupKvError::Backend,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
impl From<GroupKvRepoError> for GroupKvError {
|
||||
fn from(e: GroupKvRepoError) -> Self {
|
||||
Self::Backend(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GroupKvService for GroupKvServiceImpl {
|
||||
async fn get(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
) -> Result<Option<serde_json::Value>, GroupKvError> {
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
self.check_read(cx, group_id).await?;
|
||||
Ok(self.repo.get(group_id, collection, key).await?)
|
||||
}
|
||||
|
||||
async fn set(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
value: serde_json::Value,
|
||||
) -> Result<(), GroupKvError> {
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
let encoded_len = serde_json::to_vec(&value)
|
||||
.map(|v| v.len())
|
||||
.map_err(|e| GroupKvError::Backend(format!("encode value: {e}")))?;
|
||||
if encoded_len > self.max_value_bytes {
|
||||
return Err(GroupKvError::ValueTooLarge {
|
||||
limit: self.max_value_bytes,
|
||||
actual: encoded_len,
|
||||
});
|
||||
}
|
||||
self.check_write(cx, group_id).await?;
|
||||
self.repo.set(group_id, collection, key, value).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
) -> Result<bool, GroupKvError> {
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
self.check_write(cx, group_id).await?;
|
||||
Ok(self.repo.delete(group_id, collection, key).await?.is_some())
|
||||
}
|
||||
|
||||
async fn has(&self, cx: &SdkCallCx, collection: &str, key: &str) -> Result<bool, GroupKvError> {
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
self.check_read(cx, group_id).await?;
|
||||
Ok(self.repo.has(group_id, collection, key).await?)
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<KvListPage, GroupKvError> {
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
self.check_read(cx, group_id).await?;
|
||||
Ok(self.repo.list(group_id, collection, cursor, limit).await?)
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Tests — in-memory repo + a fake resolver so unit tests don't need Postgres.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::authz::{AuthzError, AuthzRepo};
|
||||
use picloud_shared::{
|
||||
AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, Principal, RequestId, ScriptId,
|
||||
UserId,
|
||||
};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[derive(Default)]
|
||||
struct InMemoryGroupKvRepo {
|
||||
data: Mutex<BTreeMap<(GroupId, String, String), serde_json::Value>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GroupKvRepo for InMemoryGroupKvRepo {
|
||||
async fn get(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
) -> Result<Option<serde_json::Value>, GroupKvRepoError> {
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.get(&(group_id, collection.to_string(), key.to_string()))
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn set(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
value: serde_json::Value,
|
||||
) -> Result<Option<serde_json::Value>, GroupKvRepoError> {
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.insert((group_id, collection.to_string(), key.to_string()), value))
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
) -> Result<Option<serde_json::Value>, GroupKvRepoError> {
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.remove(&(group_id, collection.to_string(), key.to_string())))
|
||||
}
|
||||
|
||||
async fn has(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
) -> Result<bool, GroupKvRepoError> {
|
||||
Ok(self.data.lock().await.contains_key(&(
|
||||
group_id,
|
||||
collection.to_string(),
|
||||
key.to_string(),
|
||||
)))
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
_cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<KvListPage, GroupKvRepoError> {
|
||||
let data = self.data.lock().await;
|
||||
let mut keys: Vec<String> = data
|
||||
.iter()
|
||||
.filter(|((g, c, _), _)| *g == group_id && c == collection)
|
||||
.map(|((_, _, k), _)| k.clone())
|
||||
.collect();
|
||||
keys.sort();
|
||||
keys.truncate((limit as usize).max(1));
|
||||
Ok(KvListPage {
|
||||
keys,
|
||||
next_cursor: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps `(app_id, lowercased name) -> owning group`. Anything absent is
|
||||
/// "not shared with you".
|
||||
#[derive(Default)]
|
||||
struct FakeResolver {
|
||||
map: HashMap<(AppId, String), GroupId>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GroupCollectionResolver for FakeResolver {
|
||||
async fn resolve_owning_group(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
_kind: &str,
|
||||
) -> Result<Option<GroupId>, sqlx::Error> {
|
||||
Ok(self.map.get(&(app_id, name.to_lowercase())).copied())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DenyingAuthzRepo;
|
||||
|
||||
#[async_trait]
|
||||
impl AuthzRepo for DenyingAuthzRepo {
|
||||
async fn membership(
|
||||
&self,
|
||||
_user_id: UserId,
|
||||
_app_id: AppId,
|
||||
) -> Result<Option<AppRole>, AuthzError> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn cx_with(app_id: AppId, principal: Option<Principal>) -> SdkCallCx {
|
||||
SdkCallCx {
|
||||
app_id,
|
||||
script_id: ScriptId::new(),
|
||||
principal,
|
||||
execution_id: ExecutionId::new(),
|
||||
request_id: RequestId::new(),
|
||||
trigger_depth: 0,
|
||||
root_execution_id: ExecutionId::new(),
|
||||
is_dead_letter_handler: false,
|
||||
event: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn owner() -> Principal {
|
||||
Principal {
|
||||
user_id: AdminUserId::new(),
|
||||
instance_role: InstanceRole::Owner,
|
||||
scopes: None,
|
||||
app_binding: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn member_no_role() -> Principal {
|
||||
Principal {
|
||||
user_id: AdminUserId::new(),
|
||||
instance_role: InstanceRole::Member,
|
||||
scopes: None,
|
||||
app_binding: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn svc(resolver: FakeResolver) -> GroupKvServiceImpl {
|
||||
GroupKvServiceImpl::new(
|
||||
Arc::new(InMemoryGroupKvRepo::default()),
|
||||
Arc::new(resolver),
|
||||
Arc::new(DenyingAuthzRepo),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unrelated_app_gets_collection_not_shared() {
|
||||
// app_a's chain declares "catalog"; app_b's does not.
|
||||
let app_a = AppId::new();
|
||||
let app_b = AppId::new();
|
||||
let group = GroupId::new();
|
||||
let mut resolver = FakeResolver::default();
|
||||
resolver.map.insert((app_a, "catalog".into()), group);
|
||||
let kv = svc(resolver);
|
||||
|
||||
// app_a (owner principal) can write+read.
|
||||
let cx_a = cx_with(app_a, Some(owner()));
|
||||
kv.set(&cx_a, "catalog", "k", serde_json::json!(1))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
kv.get(&cx_a, "catalog", "k").await.unwrap(),
|
||||
Some(serde_json::json!(1))
|
||||
);
|
||||
|
||||
// app_b is off-chain — the name does not resolve.
|
||||
let cx_b = cx_with(app_b, Some(owner()));
|
||||
let err = kv.get(&cx_b, "catalog", "k").await.unwrap_err();
|
||||
assert!(matches!(err, GroupKvError::CollectionNotShared(c) if c == "catalog"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reads_open_writes_require_auth() {
|
||||
let app = AppId::new();
|
||||
let group = GroupId::new();
|
||||
let mut resolver = FakeResolver::default();
|
||||
resolver.map.insert((app, "catalog".into()), group);
|
||||
let kv = svc(resolver);
|
||||
|
||||
// Seed a value as owner.
|
||||
let owner_cx = cx_with(app, Some(owner()));
|
||||
kv.set(&owner_cx, "catalog", "k", serde_json::json!("v"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Anonymous (principal=None) READ is allowed (reads-open).
|
||||
let anon = cx_with(app, None);
|
||||
assert_eq!(
|
||||
kv.get(&anon, "catalog", "k").await.unwrap(),
|
||||
Some(serde_json::json!("v"))
|
||||
);
|
||||
|
||||
// Anonymous WRITE fails closed.
|
||||
let err = kv
|
||||
.set(&anon, "catalog", "k", serde_json::json!("x"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, GroupKvError::Forbidden));
|
||||
|
||||
// Authenticated member with no group role: write forbidden.
|
||||
let member = cx_with(app, Some(member_no_role()));
|
||||
let err = kv.delete(&member, "catalog", "k").await.unwrap_err();
|
||||
assert!(matches!(err, GroupKvError::Forbidden));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_collection_rejected_before_resolve() {
|
||||
let kv = svc(FakeResolver::default());
|
||||
let cx = cx_with(AppId::new(), None);
|
||||
let err = kv.get(&cx, "", "k").await.unwrap_err();
|
||||
assert!(matches!(err, GroupKvError::InvalidCollection));
|
||||
}
|
||||
}
|
||||
@@ -97,10 +97,25 @@ async fn create_group_script(
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Phase 4b: group modules + imports are allowed. Imports resolve
|
||||
// lexically from this group's chain at runtime (§5.5); the recorded
|
||||
// edges feed the declarative dangling-import plan check.
|
||||
let validated = s.validator.validate(&input.source)?;
|
||||
// Phase 4b: group modules + imports are allowed. Validate per kind,
|
||||
// mirroring the app-script create path (`api.rs`): a module gets the
|
||||
// stricter shape check + the reserved-name guard; an endpoint the
|
||||
// parse-only path. Without the kind branch a malformed-shape group
|
||||
// module would be accepted here and only fail later at import time,
|
||||
// and a reserved name (`kv`, `log`, …) would slip through. Imports
|
||||
// resolve lexically from this group's chain at runtime (§5.5); the
|
||||
// recorded edges feed the declarative dangling-import plan check.
|
||||
let validated = if input.kind == ScriptKind::Module {
|
||||
if crate::api::RESERVED_MODULE_NAMES.contains(&input.name.as_str()) {
|
||||
return Err(GroupScriptsApiError::Invalid(format!(
|
||||
"{:?} is a reserved module name (shadows a built-in SDK namespace)",
|
||||
input.name
|
||||
)));
|
||||
}
|
||||
s.validator.validate_module(&input.source)?
|
||||
} else {
|
||||
s.validator.validate(&input.source)?
|
||||
};
|
||||
s.sandbox_ceiling
|
||||
.check(&input.sandbox)
|
||||
.map_err(|e| GroupScriptsApiError::Invalid(e.to_string()))?;
|
||||
|
||||
@@ -44,11 +44,19 @@ pub mod docs_repo;
|
||||
pub mod docs_service;
|
||||
pub mod email_inbound_api;
|
||||
pub mod email_service;
|
||||
pub mod extension_point_repo;
|
||||
pub mod files_api;
|
||||
pub mod files_repo;
|
||||
pub mod files_service;
|
||||
pub mod files_sweep;
|
||||
pub mod gc;
|
||||
pub mod group_collection_repo;
|
||||
pub mod group_docs_repo;
|
||||
pub mod group_docs_service;
|
||||
pub mod group_files_repo;
|
||||
pub mod group_files_service;
|
||||
pub mod group_kv_repo;
|
||||
pub mod group_kv_service;
|
||||
pub mod group_members_repo;
|
||||
pub mod group_repo;
|
||||
pub mod group_scripts_api;
|
||||
@@ -175,6 +183,13 @@ pub use files_repo::{FilesConfig, FilesRepo, FilesRepoError, FsFilesRepo};
|
||||
pub use files_service::FilesServiceImpl;
|
||||
pub use files_sweep::{spawn_files_orphan_sweep, sweep_orphan_tmp_files, SweepStats};
|
||||
pub use gc::{spawn_abandoned_gc, spawn_app_user_token_gc, spawn_dead_letter_gc};
|
||||
pub use group_collection_repo::{GroupCollectionResolver, PostgresGroupCollectionResolver};
|
||||
pub use group_docs_repo::{GroupDocsRepo, GroupDocsRepoError, PostgresGroupDocsRepo};
|
||||
pub use group_docs_service::GroupDocsServiceImpl;
|
||||
pub use group_files_repo::{FsGroupFilesRepo, GroupFilesRepo, GroupFilesRepoError};
|
||||
pub use group_files_service::GroupFilesServiceImpl;
|
||||
pub use group_kv_repo::{GroupKvRepo, GroupKvRepoError, PostgresGroupKvRepo};
|
||||
pub use group_kv_service::GroupKvServiceImpl;
|
||||
pub use group_members_repo::{
|
||||
GroupMembersRepository, GroupMembersRepositoryError, GroupMembershipDetail, GroupMembershipRow,
|
||||
PostgresGroupMembersRepository,
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
//! `PostgresModuleSource` — the Postgres-backed `ModuleSource` impl.
|
||||
//!
|
||||
//! Resolution is **lexical** (§5.5): a module is looked up by walking the
|
||||
//! group chain rooted at the **importing script's defining node**
|
||||
//! (`origin`), nearest-owner-wins — *not* the inheriting app's effective
|
||||
//! view. An `App` origin walks `app → its group → ancestors` (reusing
|
||||
//! [`CHAIN_LEVELS_CTE`]); a `Group` origin walks `group → ancestors`
|
||||
//! ([`GROUP_CHAIN_LEVELS_CTE`]) and never sees app-owned modules below it
|
||||
//! (the trust boundary). The resolver lives in `executor-core` and consumes
|
||||
//! this trait through the `Services` bundle, so manager-core stays the only
|
||||
//! crate that touches Postgres.
|
||||
//! `resolve` is **lexical** (Phase 4b): a module is looked up by walking the
|
||||
//! group chain rooted at the **importing script's defining node** (`origin`),
|
||||
//! nearest-owner-wins — *not* the inheriting app's effective view. An `App`
|
||||
//! origin walks `app → its group → ancestors` (reusing [`CHAIN_LEVELS_CTE`]);
|
||||
//! a `Group` origin walks `group → ancestors` ([`GROUP_CHAIN_LEVELS_CTE`]) and
|
||||
//! never sees app-owned modules below it (the trust boundary).
|
||||
//!
|
||||
//! `resolve_policy` adds §5.5 **extension points**: the nearest declaration of
|
||||
//! a name on `origin`'s chain decides lexical (concrete module) vs **dynamic**
|
||||
//! (an extension-point marker → resolve against the inheriting app so it can
|
||||
//! override, falling back to a default body up-chain). EP wins a depth tie.
|
||||
//!
|
||||
//! The resolver lives in `executor-core` and consumes this trait through the
|
||||
//! `Services` bundle, so manager-core stays the only crate that touches Postgres.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{ModuleScript, ModuleSource, ModuleSourceError, ScriptOwner};
|
||||
use picloud_shared::{
|
||||
AppId, ModuleResolution, ModuleScript, ModuleSource, ModuleSourceError, ScriptOwner,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::config_resolver::{CHAIN_LEVELS_CTE, GROUP_CHAIN_LEVELS_CTE};
|
||||
@@ -96,4 +103,73 @@ impl ModuleSource for PostgresModuleSource {
|
||||
.map_err(|e| ModuleSourceError::Backend(e.to_string()))?;
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
|
||||
async fn resolve_policy(
|
||||
&self,
|
||||
origin: ScriptOwner,
|
||||
inheriting_app: AppId,
|
||||
name: &str,
|
||||
) -> Result<ModuleResolution, ModuleSourceError> {
|
||||
// §5.5 nearest-declaration-kind-wins. One round trip: the min chain
|
||||
// depth at which `name` is declared as an extension-point marker vs a
|
||||
// concrete module, walking up `origin`'s chain. The EP wins a tie (a
|
||||
// marker + its co-located default body live at the same node).
|
||||
let query = match origin {
|
||||
ScriptOwner::App(_) => format!(
|
||||
"{CHAIN_LEVELS_CTE} \
|
||||
SELECT \
|
||||
(SELECT MIN(c.depth) FROM extension_points e \
|
||||
JOIN chain c ON (e.app_id = c.app_owner OR e.group_id = c.group_owner) \
|
||||
WHERE LOWER(e.name) = LOWER($2)) AS ep_depth, \
|
||||
(SELECT MIN(c.depth) FROM scripts s \
|
||||
JOIN chain c ON (s.app_id = c.app_owner OR s.group_id = c.group_owner) \
|
||||
WHERE s.kind = 'module' AND LOWER(s.name) = LOWER($2)) AS mod_depth",
|
||||
),
|
||||
ScriptOwner::Group(_) => format!(
|
||||
"{GROUP_CHAIN_LEVELS_CTE} \
|
||||
SELECT \
|
||||
(SELECT MIN(c.depth) FROM extension_points e \
|
||||
JOIN chain c ON e.group_id = c.group_owner \
|
||||
WHERE LOWER(e.name) = LOWER($2)) AS ep_depth, \
|
||||
(SELECT MIN(c.depth) FROM scripts s \
|
||||
JOIN chain c ON s.group_id = c.group_owner \
|
||||
WHERE s.kind = 'module' AND LOWER(s.name) = LOWER($2)) AS mod_depth",
|
||||
),
|
||||
};
|
||||
let root = match origin {
|
||||
ScriptOwner::App(a) => a.into_inner(),
|
||||
ScriptOwner::Group(g) => g.into_inner(),
|
||||
};
|
||||
let (ep_depth, mod_depth): (Option<i32>, Option<i32>) = sqlx::query_as(&query)
|
||||
.bind(root)
|
||||
.bind(name)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|e| ModuleSourceError::Backend(e.to_string()))?;
|
||||
|
||||
// EP wins when it is declared and is no farther than the nearest
|
||||
// concrete module (tie → EP). Then resolve dynamically against the
|
||||
// inheriting app (its own override, else the default body up-chain).
|
||||
let ep_wins = match (ep_depth, mod_depth) {
|
||||
(Some(ep), Some(m)) => ep <= m,
|
||||
(Some(_), None) => true,
|
||||
_ => false,
|
||||
};
|
||||
if ep_wins {
|
||||
return Ok(
|
||||
match self.resolve(ScriptOwner::App(inheriting_app), name).await? {
|
||||
Some(m) => ModuleResolution::Module(m),
|
||||
None => ModuleResolution::NoProvider,
|
||||
},
|
||||
);
|
||||
}
|
||||
if mod_depth.is_some() {
|
||||
// Concrete module nearest → lexical, exactly as Phase 4b.
|
||||
return Ok(match self.resolve(origin, name).await? {
|
||||
Some(m) => ModuleResolution::Module(m),
|
||||
None => ModuleResolution::NotFound,
|
||||
});
|
||||
}
|
||||
Ok(ModuleResolution::NotFound)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,6 +198,14 @@ table: execution_logs
|
||||
app_id: uuid NOT NULL
|
||||
source: text NOT NULL default='http'::text
|
||||
|
||||
table: extension_points
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
group_id: uuid NULL
|
||||
app_id: uuid NULL
|
||||
name: text NOT NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: files
|
||||
app_id: uuid NOT NULL
|
||||
collection: text NOT NULL
|
||||
@@ -214,6 +222,42 @@ table: files_trigger_details
|
||||
collection_glob: text NOT NULL
|
||||
ops: ARRAY NOT NULL
|
||||
|
||||
table: group_collections
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
group_id: uuid NULL
|
||||
app_id: uuid NULL
|
||||
name: text NOT NULL
|
||||
kind: text NOT NULL default='kv'::text
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: group_docs
|
||||
group_id: uuid NOT NULL
|
||||
collection: text NOT NULL
|
||||
id: uuid NOT NULL
|
||||
data: jsonb NOT NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: group_files
|
||||
group_id: uuid NOT NULL
|
||||
collection: text NOT NULL
|
||||
id: uuid NOT NULL
|
||||
name: text NOT NULL
|
||||
content_type: text NOT NULL
|
||||
size_bytes: bigint NOT NULL
|
||||
checksum_sha256: text NOT NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: group_kv_entries
|
||||
group_id: uuid NOT NULL
|
||||
collection: text NOT NULL
|
||||
key: text NOT NULL
|
||||
value: jsonb NOT NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: group_members
|
||||
group_id: uuid NOT NULL
|
||||
user_id: uuid NOT NULL
|
||||
@@ -463,6 +507,13 @@ indexes on execution_logs:
|
||||
execution_logs_pkey: public.execution_logs USING btree (id)
|
||||
execution_logs_script_id_created_at_idx: public.execution_logs USING btree (script_id, created_at DESC)
|
||||
|
||||
indexes on extension_points:
|
||||
extension_points_app_id_idx: public.extension_points USING btree (app_id) WHERE (app_id IS NOT NULL)
|
||||
extension_points_app_uidx: public.extension_points USING btree (app_id, lower(name)) WHERE (app_id IS NOT NULL)
|
||||
extension_points_group_id_idx: public.extension_points USING btree (group_id) WHERE (group_id IS NOT NULL)
|
||||
extension_points_group_uidx: public.extension_points USING btree (group_id, lower(name)) WHERE (group_id IS NOT NULL)
|
||||
extension_points_pkey: public.extension_points USING btree (id)
|
||||
|
||||
indexes on files:
|
||||
files_pkey: public.files USING btree (app_id, collection, id)
|
||||
idx_files_app_collection: public.files USING btree (app_id, collection)
|
||||
@@ -470,6 +521,26 @@ indexes on files:
|
||||
indexes on files_trigger_details:
|
||||
files_trigger_details_pkey: public.files_trigger_details USING btree (trigger_id)
|
||||
|
||||
indexes on group_collections:
|
||||
group_collections_app_id_idx: public.group_collections USING btree (app_id) WHERE (app_id IS NOT NULL)
|
||||
group_collections_app_uidx: public.group_collections USING btree (app_id, lower(name), kind) WHERE (app_id IS NOT NULL)
|
||||
group_collections_group_id_idx: public.group_collections USING btree (group_id) WHERE (group_id IS NOT NULL)
|
||||
group_collections_group_uidx: public.group_collections USING btree (group_id, lower(name), kind) WHERE (group_id IS NOT NULL)
|
||||
group_collections_pkey: public.group_collections USING btree (id)
|
||||
|
||||
indexes on group_docs:
|
||||
group_docs_pkey: public.group_docs USING btree (group_id, collection, id)
|
||||
idx_group_docs_data_gin: public.group_docs USING gin (data jsonb_path_ops)
|
||||
idx_group_docs_group_collection: public.group_docs USING btree (group_id, collection)
|
||||
|
||||
indexes on group_files:
|
||||
group_files_pkey: public.group_files USING btree (group_id, collection, id)
|
||||
idx_group_files_group_collection: public.group_files USING btree (group_id, collection)
|
||||
|
||||
indexes on group_kv_entries:
|
||||
group_kv_entries_pkey: public.group_kv_entries USING btree (group_id, collection, key)
|
||||
idx_group_kv_entries_group_collection: public.group_kv_entries USING btree (group_id, collection)
|
||||
|
||||
indexes on group_members:
|
||||
group_members_pkey: public.group_members USING btree (group_id, user_id)
|
||||
group_members_user_id_idx: public.group_members USING btree (user_id)
|
||||
@@ -656,6 +727,12 @@ constraints on execution_logs:
|
||||
[FOREIGN KEY] execution_logs_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE SET NULL
|
||||
[PRIMARY KEY] execution_logs_pkey: PRIMARY KEY (id)
|
||||
|
||||
constraints on extension_points:
|
||||
[CHECK] extension_points_owner_exactly_one: CHECK (((group_id IS NULL) <> (app_id IS NULL)))
|
||||
[FOREIGN KEY] extension_points_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[FOREIGN KEY] extension_points_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] extension_points_pkey: PRIMARY KEY (id)
|
||||
|
||||
constraints on files:
|
||||
[FOREIGN KEY] files_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] files_pkey: PRIMARY KEY (app_id, collection, id)
|
||||
@@ -664,6 +741,25 @@ constraints on files_trigger_details:
|
||||
[FOREIGN KEY] files_trigger_details_trigger_id_fkey: FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] files_trigger_details_pkey: PRIMARY KEY (trigger_id)
|
||||
|
||||
constraints on group_collections:
|
||||
[CHECK] group_collections_kind_check: CHECK ((kind = ANY (ARRAY['kv'::text, 'docs'::text, 'files'::text])))
|
||||
[CHECK] group_collections_owner_exactly_one: CHECK (((group_id IS NULL) <> (app_id IS NULL)))
|
||||
[FOREIGN KEY] group_collections_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[FOREIGN KEY] group_collections_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] group_collections_pkey: PRIMARY KEY (id)
|
||||
|
||||
constraints on group_docs:
|
||||
[FOREIGN KEY] group_docs_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] group_docs_pkey: PRIMARY KEY (group_id, collection, id)
|
||||
|
||||
constraints on group_files:
|
||||
[FOREIGN KEY] group_files_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] group_files_pkey: PRIMARY KEY (group_id, collection, id)
|
||||
|
||||
constraints on group_kv_entries:
|
||||
[FOREIGN KEY] group_kv_entries_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] group_kv_entries_pkey: PRIMARY KEY (group_id, collection, key)
|
||||
|
||||
constraints on group_members:
|
||||
[CHECK] group_members_role_check: CHECK ((role = ANY (ARRAY['app_admin'::text, 'editor'::text, 'viewer'::text])))
|
||||
[FOREIGN KEY] group_members_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||
@@ -800,3 +896,8 @@ constraints on vars:
|
||||
0048: vars
|
||||
0049: group secrets
|
||||
0050: group scripts
|
||||
0051: extension points
|
||||
0052: group collections
|
||||
0053: group kv entries
|
||||
0054: group docs
|
||||
0055: group files
|
||||
|
||||
@@ -1305,6 +1305,57 @@ impl NodeKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// One row of the §5.5 extension-point report.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ExtensionPointInfoDto {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub declared_here: bool,
|
||||
#[serde(default)]
|
||||
pub provider: Option<String>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// `GET /api/v1/admin/{apps|groups}/{ident}/extension-points`.
|
||||
pub async fn extension_points_list(
|
||||
&self,
|
||||
kind: NodeKind,
|
||||
ident: &str,
|
||||
) -> Result<Vec<ExtensionPointInfoDto>> {
|
||||
let ident = seg(ident);
|
||||
let resp = self
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("/api/v1/admin/{}/{ident}/extension-points", kind.path()),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/groups/{ident}/collections` (§11.6). Group-only —
|
||||
/// shared collections are declared on groups.
|
||||
pub async fn collections_list(&self, group_ident: &str) -> Result<Vec<CollectionInfoDto>> {
|
||||
let ident = seg(group_ident);
|
||||
let resp = self
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("/api/v1/admin/groups/{ident}/collections"),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
}
|
||||
|
||||
/// One row of the §11.6 shared-collection report.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CollectionInfoDto {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
// ---------- DTOs (CLI-local, wire-shape-matched) ----------
|
||||
|
||||
/// Response of `POST .../plan`: per-resource diffs grouped by kind.
|
||||
@@ -1320,6 +1371,10 @@ pub struct PlanDto {
|
||||
pub secrets: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub vars: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub extension_points: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub collections: Vec<ChangeDto>,
|
||||
/// Fingerprint of the live state this plan was computed against; carried
|
||||
/// in `.picloud/` and replayed to `apply` for the bound-plan check.
|
||||
#[serde(default)]
|
||||
@@ -1358,6 +1413,10 @@ pub struct NodePlanDto {
|
||||
pub secrets: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub vars: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub extension_points: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub collections: Vec<ChangeDto>,
|
||||
}
|
||||
|
||||
/// Response of `POST .../apply`: counts of what changed.
|
||||
@@ -1386,6 +1445,14 @@ pub struct ApplyReportDto {
|
||||
#[serde(default)]
|
||||
pub vars_deleted: u32,
|
||||
#[serde(default)]
|
||||
pub extension_points_created: u32,
|
||||
#[serde(default)]
|
||||
pub extension_points_deleted: u32,
|
||||
#[serde(default)]
|
||||
pub collections_created: u32,
|
||||
#[serde(default)]
|
||||
pub collections_deleted: u32,
|
||||
#[serde(default)]
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -84,6 +84,20 @@ pub async fn run(
|
||||
"+{} ~{} -{}",
|
||||
report.vars_created, report.vars_updated, report.vars_deleted
|
||||
),
|
||||
)
|
||||
.field(
|
||||
"extension_points",
|
||||
format!(
|
||||
"+{} -{}",
|
||||
report.extension_points_created, report.extension_points_deleted
|
||||
),
|
||||
)
|
||||
.field(
|
||||
"collections",
|
||||
format!(
|
||||
"+{} -{}",
|
||||
report.collections_created, report.collections_deleted
|
||||
),
|
||||
);
|
||||
for w in &report.warnings {
|
||||
block.field("warning", w.clone());
|
||||
@@ -151,6 +165,20 @@ pub async fn run_tree(
|
||||
"+{} ~{} -{}",
|
||||
report.vars_created, report.vars_updated, report.vars_deleted
|
||||
),
|
||||
)
|
||||
.field(
|
||||
"extension_points",
|
||||
format!(
|
||||
"+{} -{}",
|
||||
report.extension_points_created, report.extension_points_deleted
|
||||
),
|
||||
)
|
||||
.field(
|
||||
"collections",
|
||||
format!(
|
||||
"+{} -{}",
|
||||
report.collections_created, report.collections_deleted
|
||||
),
|
||||
);
|
||||
for w in &report.warnings {
|
||||
block.field("warning", w.clone());
|
||||
|
||||
23
crates/picloud-cli/src/cmds/collections.rs
Normal file
23
crates/picloud-cli/src/cmds/collections.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
//! `pic collections ls --group <g>` — read-only view of a group's §11.6 shared
|
||||
//! KV collections. Group-only (shared collections are owned by groups);
|
||||
//! authoring is declarative via the `[group]` manifest `collections = [...]`.
|
||||
//! Scripts read/write them with `kv::shared_collection("name")`.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::config;
|
||||
use crate::output::{OutputMode, Table};
|
||||
|
||||
pub async fn ls(group: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let items = client.collections_list(group).await?;
|
||||
|
||||
let mut table = Table::new(["name", "kind"]);
|
||||
for c in &items {
|
||||
table.row([c.name.clone(), c.kind.clone()]);
|
||||
}
|
||||
table.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
48
crates/picloud-cli/src/cmds/extension_points.rs
Normal file
48
crates/picloud-cli/src/cmds/extension_points.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
//! `pic extension-points ls --app|--group` — read-only view of a node's
|
||||
//! §5.5 extension points. For an app, each EP visible on its chain is shown
|
||||
//! with its resolved provider (`app override` / `inherited default` / `unset`);
|
||||
//! for a group, its own declared names. Authoring is declarative only (the
|
||||
//! manifest `extension_points = [...]`).
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
|
||||
use crate::client::{Client, NodeKind};
|
||||
use crate::config;
|
||||
use crate::output::{OutputMode, Table};
|
||||
|
||||
pub async fn ls(app: Option<&str>, group: Option<&str>, mode: OutputMode) -> Result<()> {
|
||||
let (kind, ident) = match (app, group) {
|
||||
(Some(a), None) => (NodeKind::App, a),
|
||||
(None, Some(g)) => (NodeKind::Group, g),
|
||||
_ => bail!("`extension-points ls` requires exactly one of --app or --group"),
|
||||
};
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let items = client.extension_points_list(kind, ident).await?;
|
||||
|
||||
match kind {
|
||||
NodeKind::App => {
|
||||
let mut table = Table::new(["name", "source", "provider"]);
|
||||
for ep in &items {
|
||||
table.row([
|
||||
ep.name.clone(),
|
||||
if ep.declared_here {
|
||||
"declared".into()
|
||||
} else {
|
||||
"inherited".into()
|
||||
},
|
||||
ep.provider.clone().unwrap_or_else(|| "unset".into()),
|
||||
]);
|
||||
}
|
||||
table.print(mode);
|
||||
}
|
||||
NodeKind::Group => {
|
||||
let mut table = Table::new(["name"]);
|
||||
for ep in &items {
|
||||
table.row([ep.name.clone()]);
|
||||
}
|
||||
table.print(mode);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -113,6 +113,7 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
|
||||
slug: slug.to_string(),
|
||||
name: name.to_string(),
|
||||
description: None,
|
||||
extension_points: Vec::new(),
|
||||
}),
|
||||
group: None,
|
||||
scripts: vec![ManifestScript {
|
||||
|
||||
@@ -3,8 +3,10 @@ pub mod api_keys;
|
||||
pub mod apply;
|
||||
pub mod apps;
|
||||
pub mod apps_domains;
|
||||
pub mod collections;
|
||||
pub mod config;
|
||||
pub mod dead_letters;
|
||||
pub mod extension_points;
|
||||
pub mod files;
|
||||
pub mod groups;
|
||||
pub mod init;
|
||||
|
||||
@@ -64,12 +64,14 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
|
||||
let mut table = Table::new(["node", "kind", "op", "resource", "detail"]);
|
||||
for n in &plan.nodes {
|
||||
let node = format!("{}:{}", n.kind, n.slug);
|
||||
let groups: [(&str, &Vec<ChangeDto>); 5] = [
|
||||
let groups: [(&str, &Vec<ChangeDto>); 7] = [
|
||||
("script", &n.scripts),
|
||||
("route", &n.routes),
|
||||
("trigger", &n.triggers),
|
||||
("secret", &n.secrets),
|
||||
("var", &n.vars),
|
||||
("extension_point", &n.extension_points),
|
||||
("collection", &n.collections),
|
||||
];
|
||||
for (rk, changes) in groups {
|
||||
for c in changes {
|
||||
@@ -160,6 +162,12 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
|
||||
"triggers": triggers,
|
||||
"secrets": manifest.secrets.names,
|
||||
"vars": vars,
|
||||
"extension_points": manifest.extension_points(),
|
||||
"collections": manifest
|
||||
.collections()
|
||||
.into_iter()
|
||||
.map(|(name, kind)| json!({ "name": name, "kind": kind }))
|
||||
.collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -174,12 +182,14 @@ fn tagged(kind: &str, spec: impl Serialize) -> Result<Value> {
|
||||
|
||||
fn render(plan: &PlanDto, mode: OutputMode) {
|
||||
let mut table = Table::new(["kind", "op", "resource", "detail"]);
|
||||
let groups: [(&str, &Vec<ChangeDto>); 5] = [
|
||||
let groups: [(&str, &Vec<ChangeDto>); 7] = [
|
||||
("script", &plan.scripts),
|
||||
("route", &plan.routes),
|
||||
("trigger", &plan.triggers),
|
||||
("secret", &plan.secrets),
|
||||
("var", &plan.vars),
|
||||
("extension_point", &plan.extension_points),
|
||||
("collection", &plan.collections),
|
||||
];
|
||||
for (kind, changes) in groups {
|
||||
for c in changes {
|
||||
|
||||
@@ -49,6 +49,15 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
||||
.secrets_list(VarOwnerArg::App(app_ident), None)
|
||||
.await?
|
||||
.secrets;
|
||||
// The app's OWN declared extension points (§5.5) — inherited ones belong to
|
||||
// the ancestor group's manifest, so filter to `declared_here`.
|
||||
let extension_points: Vec<String> = client
|
||||
.extension_points_list(crate::client::NodeKind::App, app_ident)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|ep| ep.declared_here)
|
||||
.map(|ep| ep.name)
|
||||
.collect();
|
||||
// App's OWN env-agnostic vars become the manifest `[vars]` block. Inherited
|
||||
// group vars and tombstones are excluded (pull exports own rows only, §4.6);
|
||||
// a value TOML can't represent (e.g. JSON null) is skipped with a warning.
|
||||
@@ -215,6 +224,7 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
||||
slug: app.app.slug.clone(),
|
||||
name: app.app.name.clone(),
|
||||
description: app.app.description.clone(),
|
||||
extension_points,
|
||||
}),
|
||||
group: None,
|
||||
scripts: manifest_scripts,
|
||||
|
||||
@@ -153,6 +153,24 @@ enum Cmd {
|
||||
cmd: VarsCmd,
|
||||
},
|
||||
|
||||
/// Extension points (§5.5) — read-only view of the module names a node
|
||||
/// marks as overridable. Authored declaratively via the manifest
|
||||
/// `extension_points = [...]`; this lists them + (for an app) each one's
|
||||
/// resolved provider.
|
||||
ExtensionPoints {
|
||||
#[command(subcommand)]
|
||||
cmd: ExtensionPointsCmd,
|
||||
},
|
||||
|
||||
/// Shared group collections (§11.6) — read-only view of the KV collection
|
||||
/// names a group offers as cross-app-shared. Authored declaratively via the
|
||||
/// `[group]` manifest `collections = [...]`; scripts read/write them with
|
||||
/// `kv::shared_collection("name")`.
|
||||
Collections {
|
||||
#[command(subcommand)]
|
||||
cmd: CollectionsCmd,
|
||||
},
|
||||
|
||||
/// Files inspection — list a collection's blobs, download bytes, or
|
||||
/// delete a file. Read + delete only; writes go through scripts.
|
||||
Files {
|
||||
@@ -537,6 +555,28 @@ enum DomainsCmd {
|
||||
Rm { app: String, domain_id: String },
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum ExtensionPointsCmd {
|
||||
/// List a node's extension points. `--app` shows every EP visible on the
|
||||
/// app's chain with its resolved provider; `--group` shows the group's own.
|
||||
Ls {
|
||||
#[arg(long)]
|
||||
app: Option<String>,
|
||||
#[arg(long, conflicts_with = "app")]
|
||||
group: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum CollectionsCmd {
|
||||
/// List the shared collections a group declares (§11.6). Group-only —
|
||||
/// shared collections are owned by groups, not apps.
|
||||
Ls {
|
||||
#[arg(long)]
|
||||
group: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum ScriptsCmd {
|
||||
/// List scripts. With `--app`, scoped to one app; with `--group`, a
|
||||
@@ -1961,6 +2001,12 @@ async fn main() -> ExitCode {
|
||||
env,
|
||||
},
|
||||
} => cmds::vars::rm(group.as_deref(), app.as_deref(), &key, env.as_deref()).await,
|
||||
Cmd::ExtensionPoints {
|
||||
cmd: ExtensionPointsCmd::Ls { app, group },
|
||||
} => cmds::extension_points::ls(app.as_deref(), group.as_deref(), mode).await,
|
||||
Cmd::Collections {
|
||||
cmd: CollectionsCmd::Ls { group },
|
||||
} => cmds::collections::ls(&group, mode).await,
|
||||
Cmd::Files {
|
||||
cmd:
|
||||
FilesCmd::Ls {
|
||||
|
||||
@@ -29,6 +29,7 @@ use serde::{Deserialize, Serialize};
|
||||
pub const MANIFEST_FILE: &str = "picloud.toml";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Manifest {
|
||||
/// An app node declares `[app]`; a group node declares `[group]` (Phase 5).
|
||||
/// Exactly one is present (enforced by [`Manifest::parse`]).
|
||||
@@ -99,6 +100,33 @@ impl Manifest {
|
||||
self.group.is_some()
|
||||
}
|
||||
|
||||
/// This node's declared extension-point names (§5.5), from whichever of
|
||||
/// `[app]` / `[group]` is present.
|
||||
#[must_use]
|
||||
pub fn extension_points(&self) -> &[String] {
|
||||
match (&self.app, &self.group) {
|
||||
(Some(a), _) => &a.extension_points,
|
||||
(_, Some(g)) => &g.extension_points,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// This node's declared shared group collections (§11.6), normalized to
|
||||
/// `(name, kind)` pairs. Authored on `[group]` nodes only — an `[app]`
|
||||
/// manifest carrying `collections` is a hard parse error (`ManifestApp` has
|
||||
/// no such field + `deny_unknown_fields`).
|
||||
#[must_use]
|
||||
pub fn collections(&self) -> Vec<(String, String)> {
|
||||
match &self.group {
|
||||
Some(g) => g
|
||||
.collections
|
||||
.iter()
|
||||
.map(CollectionDecl::normalized)
|
||||
.collect(),
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load and parse the manifest at `path`.
|
||||
pub fn load(path: &Path) -> Result<Self> {
|
||||
let body =
|
||||
@@ -198,11 +226,18 @@ pub struct OverlayApp {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ManifestApp {
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
/// `extension_points = ["theme", …]` (§5.5) — module names this node marks
|
||||
/// as provided/overridable by descendants. Name-only, like `[secrets]`; the
|
||||
/// optional default body is a co-located `[[scripts]]` module of the same
|
||||
/// name. A key of the `[app]` table so TOML can't mis-nest it.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub extension_points: Vec<String>,
|
||||
}
|
||||
|
||||
/// A `[group]` node (Phase 5): a group's own declarative content — its scripts
|
||||
@@ -210,11 +245,71 @@ pub struct ManifestApp {
|
||||
/// `pic groups create`); the manifest reconciles its content, not the tree
|
||||
/// shape. In a nested project the parent is inferred from the directory tree.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ManifestGroup {
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
/// See [`ManifestApp::extension_points`]. A key of the `[group]` table.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub extension_points: Vec<String>,
|
||||
/// `collections = [...]` (§11.6) — shared collections this group offers as
|
||||
/// cross-app-shared (read by any app in the subtree, written by an
|
||||
/// authenticated editor+). Each entry is either a bare string (a `kv`
|
||||
/// collection) or a `{ name, kind }` table (`kind` ∈ `kv`/`docs`).
|
||||
/// Group-only: there is no `collections` key on `[app]`.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub collections: Vec<CollectionDecl>,
|
||||
}
|
||||
|
||||
/// The store kind of a shared collection (§11.6).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CollectionKind {
|
||||
Kv,
|
||||
Docs,
|
||||
Files,
|
||||
}
|
||||
|
||||
impl CollectionKind {
|
||||
#[must_use]
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Kv => "kv",
|
||||
Self::Docs => "docs",
|
||||
Self::Files => "files",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One `collections` entry: a bare string (`kv` shorthand) or a `{ name, kind }`
|
||||
/// table. Untagged so the shipped `collections = ["catalog"]` form keeps working
|
||||
/// alongside `{ name = "articles", kind = "docs" }`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum CollectionDecl {
|
||||
Name(String),
|
||||
Full {
|
||||
name: String,
|
||||
#[serde(default = "kv_kind")]
|
||||
kind: CollectionKind,
|
||||
},
|
||||
}
|
||||
|
||||
fn kv_kind() -> CollectionKind {
|
||||
CollectionKind::Kv
|
||||
}
|
||||
|
||||
impl CollectionDecl {
|
||||
/// `(name, kind-as-string)` — a bare string normalizes to `kind = "kv"`.
|
||||
#[must_use]
|
||||
pub fn normalized(&self) -> (String, String) {
|
||||
match self {
|
||||
Self::Name(n) => (n.clone(), "kv".to_string()),
|
||||
Self::Full { name, kind } => (name.clone(), kind.as_str().to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
@@ -426,6 +521,7 @@ mod tests {
|
||||
slug: "blog".into(),
|
||||
name: "My Blog".into(),
|
||||
description: Some("demo".into()),
|
||||
extension_points: vec!["theme".into()],
|
||||
}),
|
||||
group: None,
|
||||
scripts: vec![
|
||||
@@ -584,6 +680,7 @@ mod tests {
|
||||
slug: "x".into(),
|
||||
name: "X".into(),
|
||||
description: None,
|
||||
extension_points: vec![],
|
||||
}),
|
||||
group: None,
|
||||
scripts: vec![],
|
||||
@@ -597,10 +694,61 @@ mod tests {
|
||||
assert!(!text.contains("triggers"), "got:\n{text}");
|
||||
assert!(!text.contains("secrets"), "got:\n{text}");
|
||||
assert!(!text.contains("vars"), "got:\n{text}");
|
||||
assert!(!text.contains("extension_points"), "got:\n{text}");
|
||||
// Still round-trips.
|
||||
assert_eq!(m, Manifest::parse(&text).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_misplaced_or_unknown_keys() {
|
||||
// §5.5 footgun guard: `extension_points` is an `[app]`/`[group]` node
|
||||
// key. Placed at top level (before any table header) TOML reads it as a
|
||||
// root key — `deny_unknown_fields` must reject it loudly rather than
|
||||
// silently drop it (the silent-drop bug that bit in C5).
|
||||
let misplaced = "extension_points = [\"theme\"]\n[app]\nslug = \"x\"\nname = \"X\"\n";
|
||||
assert!(
|
||||
Manifest::parse(misplaced).is_err(),
|
||||
"a top-level extension_points must be rejected, not silently ignored"
|
||||
);
|
||||
// A typo'd key inside [app] is likewise a hard error, not a drop.
|
||||
let typo = "[app]\nslug = \"x\"\nname = \"X\"\nextention_points = [\"theme\"]\n";
|
||||
assert!(
|
||||
Manifest::parse(typo).is_err(),
|
||||
"an unknown key in [app] must be rejected"
|
||||
);
|
||||
// The correct node-key placement still parses.
|
||||
let ok = "[app]\nslug = \"x\"\nname = \"X\"\nextension_points = [\"theme\"]\n";
|
||||
let m = Manifest::parse(ok).expect("node-key extension_points must parse");
|
||||
assert_eq!(m.extension_points(), ["theme"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collections_string_or_table_normalizes_kind() {
|
||||
// §11.6: a bare string is a `kv` collection (the shipped form); a
|
||||
// `{ name, kind }` table sets an explicit kind. Both coexist.
|
||||
let m = Manifest::parse(
|
||||
"[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\
|
||||
collections = [\"catalog\", { name = \"articles\", kind = \"docs\" }, \
|
||||
{ name = \"assets\", kind = \"files\" }]\n",
|
||||
)
|
||||
.expect("string-or-table collections must parse");
|
||||
assert_eq!(
|
||||
m.collections(),
|
||||
vec![
|
||||
("catalog".to_string(), "kv".to_string()),
|
||||
("articles".to_string(), "docs".to_string()),
|
||||
("assets".to_string(), "files".to_string()),
|
||||
]
|
||||
);
|
||||
// An app manifest carrying `collections` is rejected (no such field +
|
||||
// deny_unknown_fields).
|
||||
let app = "[app]\nslug = \"x\"\nname = \"X\"\ncollections = [\"catalog\"]\n";
|
||||
assert!(
|
||||
Manifest::parse(app).is_err(),
|
||||
"collections on [app] must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_manifest_parses_and_rejects_app_only_blocks() {
|
||||
// A [group] node: scripts + vars, no [app].
|
||||
|
||||
@@ -18,11 +18,13 @@ mod api_keys;
|
||||
mod apply;
|
||||
mod apps;
|
||||
mod auth;
|
||||
mod collections;
|
||||
mod config;
|
||||
mod dead_letters;
|
||||
mod email_queue;
|
||||
mod enabled;
|
||||
mod env_overlay;
|
||||
mod extension_points;
|
||||
mod group_modules;
|
||||
mod group_scripts;
|
||||
mod group_secrets;
|
||||
|
||||
558
crates/picloud-cli/tests/collections.rs
Normal file
558
crates/picloud-cli/tests/collections.rs
Normal file
@@ -0,0 +1,558 @@
|
||||
//! §11.6 shared group collections, end to end via `pic`:
|
||||
//! * a group declares a shared KV collection `catalog`; two apps under it
|
||||
//! share one store — an authenticated write in app A is read back in app B
|
||||
//! (cross-app data sharing, the deliberate inverse of per-app isolation),
|
||||
//! * an app under a SIBLING group naming `catalog` gets CollectionNotShared
|
||||
//! (the ancestry walk is the isolation boundary),
|
||||
//! * `pic collections ls --group` lists the declared name; re-plan is NoOp.
|
||||
//!
|
||||
//! The anonymous-write-fails-closed half of the trust model is unit-tested in
|
||||
//! `group_kv_service` (a journey invoke always carries the admin principal).
|
||||
|
||||
use std::fs;
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::{AppGuard, GroupGuard};
|
||||
|
||||
fn manifest_dir() -> TempDir {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
fs::create_dir_all(dir.path().join("scripts")).expect("scripts dir");
|
||||
dir
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn shared_collection_is_read_write_across_the_subtree() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let group = common::unique_slug("coll-grp");
|
||||
let sibling = common::unique_slug("coll-sib");
|
||||
let app_a = common::unique_slug("coll-a");
|
||||
let app_b = common::unique_slug("coll-b");
|
||||
let foreign = common::unique_slug("coll-f");
|
||||
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
let _gs = GroupGuard::new(&env.url, &env.token, &sibling);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group])
|
||||
.assert()
|
||||
.success();
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &sibling])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// The group declares `catalog` a shared collection (declarative apply).
|
||||
let dir = manifest_dir();
|
||||
let gmanifest =
|
||||
format!("[group]\nslug = \"{group}\"\nname = \"Coll\"\n\ncollections = [\"catalog\"]\n");
|
||||
let gpath = dir.path().join("group.toml");
|
||||
fs::write(&gpath, &gmanifest).unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&gpath)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// `collections ls --group` shows the declared name.
|
||||
let ls = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["collections", "ls", "--group", &group])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
ls.contains("catalog"),
|
||||
"ls should list the collection:\n{ls}"
|
||||
);
|
||||
|
||||
// Re-apply is a no-op (the marker already exists).
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&gpath)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Two apps under the group; one under a sibling group.
|
||||
let _a = AppGuard::new(&env.url, &env.token, &app_a);
|
||||
let _b = AppGuard::new(&env.url, &env.token, &app_b);
|
||||
let _f = AppGuard::new(&env.url, &env.token, &foreign);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &app_a, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &app_b, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &foreign, "--group", &sibling])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// App A writes to the shared collection (authenticated invoke → admin
|
||||
// principal, so the editor+ write gate is satisfied).
|
||||
fs::write(
|
||||
dir.path().join("scripts/writer.rhai"),
|
||||
r#"kv::shared_collection("catalog").set("sku-1", #{ price: 9 }); "ok""#,
|
||||
)
|
||||
.unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/writer.rhai"))
|
||||
.args(["--app", &app_a, "--name", "writer"])
|
||||
.assert()
|
||||
.success();
|
||||
let writer_id = app_script_id(&env, &app_a, "writer");
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "invoke", &writer_id])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// App B reads the SAME store back — cross-app sharing.
|
||||
fs::write(
|
||||
dir.path().join("scripts/reader.rhai"),
|
||||
r#"kv::shared_collection("catalog").get("sku-1")"#,
|
||||
)
|
||||
.unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/reader.rhai"))
|
||||
.args(["--app", &app_b, "--name", "reader"])
|
||||
.assert()
|
||||
.success();
|
||||
let reader_id = app_script_id(&env, &app_b, "reader");
|
||||
assert_eq!(
|
||||
invoke_body(&env, &reader_id),
|
||||
serde_json::json!({ "price": 9 }),
|
||||
"an app in the subtree reads what another app wrote to the shared collection"
|
||||
);
|
||||
|
||||
// The foreign app (sibling subtree) names `catalog` but it does not resolve
|
||||
// on its chain — CollectionNotShared. The invoke fails.
|
||||
fs::write(
|
||||
dir.path().join("scripts/foreign.rhai"),
|
||||
r#"kv::shared_collection("catalog").get("sku-1")"#,
|
||||
)
|
||||
.unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/foreign.rhai"))
|
||||
.args(["--app", &foreign, "--name", "peek"])
|
||||
.assert()
|
||||
.success();
|
||||
let foreign_id = app_script_id(&env, &foreign, "peek");
|
||||
let out = common::pic_as(&env)
|
||||
.args(["scripts", "invoke", &foreign_id])
|
||||
.output()
|
||||
.expect("invoke");
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"a foreign app must not resolve another subtree's shared collection"
|
||||
);
|
||||
}
|
||||
|
||||
/// Nearest-ancestor-group-wins (§11.6, the CoW-shadowing guarantee). A parent
|
||||
/// AND a child group both declare `catalog`; an app under the child must bind
|
||||
/// the CHILD's store, not the parent's. Discriminated by a second app directly
|
||||
/// under the parent: it resolves to the PARENT's store, so it must NOT see what
|
||||
/// the deep app wrote. If the resolver's `ORDER BY depth LIMIT 1` regressed to
|
||||
/// the farther group, the shallow app would see the deep app's value.
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn nearest_declaring_group_wins() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let root = common::unique_slug("nw-root");
|
||||
let team = common::unique_slug("nw-team");
|
||||
let deep = common::unique_slug("nw-deep");
|
||||
let shallow = common::unique_slug("nw-shallow");
|
||||
|
||||
let _gr = GroupGuard::new(&env.url, &env.token, &root);
|
||||
let _gt = GroupGuard::new(&env.url, &env.token, &team);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &root])
|
||||
.assert()
|
||||
.success();
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &team, "--parent", &root])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// BOTH groups declare `catalog` (two distinct stores, same name).
|
||||
let dir = manifest_dir();
|
||||
for g in [&root, &team] {
|
||||
let m = format!("[group]\nslug = \"{g}\"\nname = \"NW\"\n\ncollections = [\"catalog\"]\n");
|
||||
let p = dir.path().join(format!("{g}.toml"));
|
||||
fs::write(&p, &m).unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&p)
|
||||
.assert()
|
||||
.success();
|
||||
}
|
||||
|
||||
// `deep` under team, `shallow` directly under root.
|
||||
let _ad = AppGuard::new(&env.url, &env.token, &deep);
|
||||
let _as = AppGuard::new(&env.url, &env.token, &shallow);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &deep, "--group", &team])
|
||||
.assert()
|
||||
.success();
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &shallow, "--group", &root])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// The deep app writes — must land in TEAM's store (nearest declarer).
|
||||
fs::write(
|
||||
dir.path().join("scripts/w.rhai"),
|
||||
r#"kv::shared_collection("catalog").set("k", "team-value"); "ok""#,
|
||||
)
|
||||
.unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/w.rhai"))
|
||||
.args(["--app", &deep, "--name", "w"])
|
||||
.assert()
|
||||
.success();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "invoke", &app_script_id(&env, &deep, "w")])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// The deep app reads its own write back (team's store).
|
||||
fs::write(
|
||||
dir.path().join("scripts/r.rhai"),
|
||||
r#"kv::shared_collection("catalog").get("k")"#,
|
||||
)
|
||||
.unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/r.rhai"))
|
||||
.args(["--app", &deep, "--name", "r"])
|
||||
.assert()
|
||||
.success();
|
||||
assert_eq!(
|
||||
invoke_body(&env, &app_script_id(&env, &deep, "r")),
|
||||
serde_json::json!("team-value"),
|
||||
"the deep app reads back its own write to the nearest (team) store"
|
||||
);
|
||||
|
||||
// The shallow app reads catalog → ROOT's store, which is empty. Seeing
|
||||
// `team-value` here would mean the write leaked to the farther group.
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/r.rhai"))
|
||||
.args(["--app", &shallow, "--name", "r"])
|
||||
.assert()
|
||||
.success();
|
||||
assert_eq!(
|
||||
invoke_body(&env, &app_script_id(&env, &shallow, "r")),
|
||||
serde_json::Value::Null,
|
||||
"the shallow app resolves the ROOT store (empty) — nearest-wins kept the \
|
||||
deep write in the team store"
|
||||
);
|
||||
}
|
||||
|
||||
/// §11.6 docs slice: a group declares a `docs`-kind shared collection (via the
|
||||
/// string-or-table manifest, mixed with a `kv` one); an authenticated app A
|
||||
/// `create`s a document and app B `find`s it back across the subtree; a
|
||||
/// sibling-subtree app gets `CollectionNotShared`; `collections ls` shows both
|
||||
/// kinds.
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn shared_docs_collection_is_read_write_across_the_subtree() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let group = common::unique_slug("dcoll-grp");
|
||||
let sibling = common::unique_slug("dcoll-sib");
|
||||
let app_a = common::unique_slug("dcoll-a");
|
||||
let app_b = common::unique_slug("dcoll-b");
|
||||
let foreign = common::unique_slug("dcoll-f");
|
||||
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
let _gs = GroupGuard::new(&env.url, &env.token, &sibling);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group])
|
||||
.assert()
|
||||
.success();
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &sibling])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// The group declares a kv AND a docs collection (string-or-table form).
|
||||
let dir = manifest_dir();
|
||||
let gmanifest = format!(
|
||||
"[group]\nslug = \"{group}\"\nname = \"DColl\"\n\n\
|
||||
collections = [\"catalog\", {{ name = \"articles\", kind = \"docs\" }}]\n"
|
||||
);
|
||||
let gpath = dir.path().join("group.toml");
|
||||
fs::write(&gpath, &gmanifest).unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&gpath)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// `collections ls` shows both names with their kinds.
|
||||
let ls = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["collections", "ls", "--group", &group])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
ls.contains("articles") && ls.contains("docs") && ls.contains("catalog"),
|
||||
"ls should list both kinds:\n{ls}"
|
||||
);
|
||||
|
||||
// Re-apply is a no-op (both markers already exist).
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&gpath)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let _a = AppGuard::new(&env.url, &env.token, &app_a);
|
||||
let _b = AppGuard::new(&env.url, &env.token, &app_b);
|
||||
let _f = AppGuard::new(&env.url, &env.token, &foreign);
|
||||
for (app, grp) in [(&app_a, &group), (&app_b, &group), (&foreign, &sibling)] {
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", app, "--group", grp])
|
||||
.assert()
|
||||
.success();
|
||||
}
|
||||
|
||||
// App A creates a document in the shared docs collection (authenticated).
|
||||
fs::write(
|
||||
dir.path().join("scripts/dwriter.rhai"),
|
||||
r#"docs::shared_collection("articles").create(#{ t: "hi" })"#,
|
||||
)
|
||||
.unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/dwriter.rhai"))
|
||||
.args(["--app", &app_a, "--name", "dwriter"])
|
||||
.assert()
|
||||
.success();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "invoke", &app_script_id(&env, &app_a, "dwriter")])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// App B finds the SAME doc back — cross-app docs sharing + the find DSL.
|
||||
fs::write(
|
||||
dir.path().join("scripts/dreader.rhai"),
|
||||
r#"docs::shared_collection("articles").find(#{ t: "hi" })[0].data.t"#,
|
||||
)
|
||||
.unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/dreader.rhai"))
|
||||
.args(["--app", &app_b, "--name", "dreader"])
|
||||
.assert()
|
||||
.success();
|
||||
assert_eq!(
|
||||
invoke_body(&env, &app_script_id(&env, &app_b, "dreader")),
|
||||
serde_json::json!("hi"),
|
||||
"app B finds the document app A created in the shared docs collection"
|
||||
);
|
||||
|
||||
// The foreign app (sibling subtree) → CollectionNotShared.
|
||||
fs::write(
|
||||
dir.path().join("scripts/dpeek.rhai"),
|
||||
r#"docs::shared_collection("articles").find(#{})"#,
|
||||
)
|
||||
.unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/dpeek.rhai"))
|
||||
.args(["--app", &foreign, "--name", "dpeek"])
|
||||
.assert()
|
||||
.success();
|
||||
let out = common::pic_as(&env)
|
||||
.args(["scripts", "invoke", &app_script_id(&env, &foreign, "dpeek")])
|
||||
.output()
|
||||
.expect("invoke");
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"a foreign app must not resolve another subtree's shared docs collection"
|
||||
);
|
||||
}
|
||||
|
||||
/// §11.6 files slice: a group declares a `files`-kind shared collection (mixed
|
||||
/// with kv + docs in one string-or-table manifest); an authenticated app A
|
||||
/// `create`s a blob and app B `list`s + `get`s the SAME bytes back across the
|
||||
/// subtree (the group-keyed disk path under `files/groups/<group_id>/...`); a
|
||||
/// sibling-subtree app gets `CollectionNotShared`; `collections ls` shows all
|
||||
/// three kinds.
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn shared_files_collection_is_read_write_across_the_subtree() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let group = common::unique_slug("fcoll-grp");
|
||||
let sibling = common::unique_slug("fcoll-sib");
|
||||
let app_a = common::unique_slug("fcoll-a");
|
||||
let app_b = common::unique_slug("fcoll-b");
|
||||
let foreign = common::unique_slug("fcoll-f");
|
||||
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
let _gs = GroupGuard::new(&env.url, &env.token, &sibling);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group])
|
||||
.assert()
|
||||
.success();
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &sibling])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// The group declares kv + docs + files collections in one manifest.
|
||||
let dir = manifest_dir();
|
||||
let gmanifest = format!(
|
||||
"[group]\nslug = \"{group}\"\nname = \"FColl\"\n\n\
|
||||
collections = [\"catalog\", {{ name = \"articles\", kind = \"docs\" }}, \
|
||||
{{ name = \"assets\", kind = \"files\" }}]\n"
|
||||
);
|
||||
let gpath = dir.path().join("group.toml");
|
||||
fs::write(&gpath, &gmanifest).unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&gpath)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// `collections ls` shows all three kinds.
|
||||
let ls = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["collections", "ls", "--group", &group])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
ls.contains("assets") && ls.contains("files"),
|
||||
"ls should list the files collection:\n{ls}"
|
||||
);
|
||||
|
||||
// Re-apply is a no-op (all three markers already exist).
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&gpath)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let _a = AppGuard::new(&env.url, &env.token, &app_a);
|
||||
let _b = AppGuard::new(&env.url, &env.token, &app_b);
|
||||
let _f = AppGuard::new(&env.url, &env.token, &foreign);
|
||||
for (app, grp) in [(&app_a, &group), (&app_b, &group), (&foreign, &sibling)] {
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", app, "--group", grp])
|
||||
.assert()
|
||||
.success();
|
||||
}
|
||||
|
||||
// App A creates a blob in the shared files collection (authenticated).
|
||||
// base64("hi") = "aGk=".
|
||||
fs::write(
|
||||
dir.path().join("scripts/fwriter.rhai"),
|
||||
r#"files::shared_collection("assets").create(#{ name: "a.txt", content_type: "text/plain", data: base64::decode("aGk=") })"#,
|
||||
)
|
||||
.unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/fwriter.rhai"))
|
||||
.args(["--app", &app_a, "--name", "fwriter"])
|
||||
.assert()
|
||||
.success();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "invoke", &app_script_id(&env, &app_a, "fwriter")])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// App B lists the shared collection and reads the SAME bytes back —
|
||||
// cross-app blob sharing via the group-keyed disk path.
|
||||
fs::write(
|
||||
dir.path().join("scripts/freader.rhai"),
|
||||
r#"let c = files::shared_collection("assets"); let p = c.list(); base64::encode(c.get(p.files[0].id))"#,
|
||||
)
|
||||
.unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/freader.rhai"))
|
||||
.args(["--app", &app_b, "--name", "freader"])
|
||||
.assert()
|
||||
.success();
|
||||
assert_eq!(
|
||||
invoke_body(&env, &app_script_id(&env, &app_b, "freader")),
|
||||
serde_json::json!("aGk="),
|
||||
"app B reads the blob app A wrote to the shared files collection"
|
||||
);
|
||||
|
||||
// The foreign app (sibling subtree) → CollectionNotShared.
|
||||
fs::write(
|
||||
dir.path().join("scripts/fpeek.rhai"),
|
||||
r#"files::shared_collection("assets").list()"#,
|
||||
)
|
||||
.unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/fpeek.rhai"))
|
||||
.args(["--app", &foreign, "--name", "fpeek"])
|
||||
.assert()
|
||||
.success();
|
||||
let out = common::pic_as(&env)
|
||||
.args(["scripts", "invoke", &app_script_id(&env, &foreign, "fpeek")])
|
||||
.output()
|
||||
.expect("invoke");
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"a foreign app must not resolve another subtree's shared files collection"
|
||||
);
|
||||
}
|
||||
|
||||
/// Id of the named script in an app (`pic scripts ls --app <a>`).
|
||||
fn app_script_id(env: &common::TestEnv, app: &str, name: &str) -> String {
|
||||
let ls = common::pic_as(env)
|
||||
.args(["scripts", "ls", "--app", app])
|
||||
.output()
|
||||
.expect("scripts ls");
|
||||
let table = String::from_utf8(ls.stdout).unwrap();
|
||||
table
|
||||
.lines()
|
||||
.map(common::cells)
|
||||
.find(|c| c.get(2) == Some(&name))
|
||||
.and_then(|c| c.first().map(|s| (*s).to_string()))
|
||||
.unwrap_or_else(|| panic!("script `{name}` not found:\n{table}"))
|
||||
}
|
||||
|
||||
fn invoke_body(env: &common::TestEnv, id: &str) -> serde_json::Value {
|
||||
let out = common::pic_as(env)
|
||||
.args(["scripts", "invoke", id])
|
||||
.output()
|
||||
.expect("scripts invoke");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"invoke failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
serde_json::from_slice(&out.stdout).expect("invoke body is JSON")
|
||||
}
|
||||
231
crates/picloud-cli/tests/extension_points.rs
Normal file
231
crates/picloud-cli/tests/extension_points.rs
Normal file
@@ -0,0 +1,231 @@
|
||||
//! §5.5 opt-in extension points, end to end via `pic`:
|
||||
//! * a group marks a module `theme` an extension point (default body) and an
|
||||
//! endpoint `render` imports it; an app inherits → uses the DEFAULT,
|
||||
//! * the app provides its OWN `theme` module → `render` now binds the app's
|
||||
//! (DYNAMIC override — the inverse of the Phase 4b sealed/lexical import),
|
||||
//! * `pic extension-points ls --app` shows the resolved provider,
|
||||
//! * an inherited extension point with NO provider is a `pic plan` error.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::{AppGuard, GroupGuard, ScriptGuard};
|
||||
|
||||
fn manifest_dir() -> TempDir {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
fs::create_dir_all(dir.path().join("scripts")).expect("scripts dir");
|
||||
dir
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn extension_point_default_then_app_override() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let group = common::unique_slug("ep-grp");
|
||||
let child = common::unique_slug("ep-child");
|
||||
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let dir = manifest_dir();
|
||||
// Group default `theme` module + a `render` endpoint importing it.
|
||||
fs::write(
|
||||
dir.path().join("scripts/theme.rhai"),
|
||||
r#"fn color() { "blue" }"#,
|
||||
)
|
||||
.unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/theme.rhai"))
|
||||
.args(["--group", &group, "--name", "theme", "--kind", "module"])
|
||||
.assert()
|
||||
.success();
|
||||
fs::write(
|
||||
dir.path().join("scripts/render.rhai"),
|
||||
r#"import "theme" as t; t::color()"#,
|
||||
)
|
||||
.unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/render.rhai"))
|
||||
.args(["--group", &group, "--name", "render"])
|
||||
.assert()
|
||||
.success();
|
||||
let _gt = ScriptGuard::new(
|
||||
&env.url,
|
||||
&env.token,
|
||||
&group_script_id(&env, &group, "theme"),
|
||||
);
|
||||
let _gr = ScriptGuard::new(
|
||||
&env.url,
|
||||
&env.token,
|
||||
&group_script_id(&env, &group, "render"),
|
||||
);
|
||||
|
||||
// Mark `theme` an extension point at the group (declarative apply).
|
||||
let gmanifest =
|
||||
format!("[group]\nslug = \"{group}\"\nname = \"EP\"\n\nextension_points = [\"theme\"]\n");
|
||||
let gpath = dir.path().join("group.toml");
|
||||
fs::write(&gpath, &gmanifest).unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&gpath)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// App under the group; a `caller` invokes the inherited `render`.
|
||||
let _child = AppGuard::new(&env.url, &env.token, &child);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &child, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
fs::write(
|
||||
dir.path().join("scripts/caller.rhai"),
|
||||
r#"invoke("render", #{})"#,
|
||||
)
|
||||
.unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/caller.rhai"))
|
||||
.args(["--app", &child, "--name", "caller"])
|
||||
.assert()
|
||||
.success();
|
||||
let caller_id = app_script_id(&env, &child, "caller");
|
||||
|
||||
// Default: with no app override, `render`'s EP import falls back to the
|
||||
// group's default body → "blue".
|
||||
assert_eq!(
|
||||
invoke_body(&env, &caller_id),
|
||||
serde_json::json!("blue"),
|
||||
"an inherited extension point resolves the group's default body"
|
||||
);
|
||||
|
||||
// DYNAMIC OVERRIDE: the app provides its own `theme`. Because `theme` is an
|
||||
// extension point, the group endpoint's import now binds the APP's module —
|
||||
// the exact inverse of a Phase 4b lexical (sealed) import.
|
||||
fs::write(
|
||||
dir.path().join("scripts/apptheme.rhai"),
|
||||
r#"fn color() { "red" }"#,
|
||||
)
|
||||
.unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/apptheme.rhai"))
|
||||
.args(["--app", &child, "--name", "theme", "--kind", "module"])
|
||||
.assert()
|
||||
.success();
|
||||
assert_eq!(
|
||||
invoke_body(&env, &caller_id),
|
||||
serde_json::json!("red"),
|
||||
"the app must override an extension point the group declared"
|
||||
);
|
||||
|
||||
// `extension-points ls --app` shows the resolved provider.
|
||||
let ls = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["extension-points", "ls", "--app", &child])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
ls.contains("theme") && ls.contains("app override"),
|
||||
"ls should report the app override:\n{ls}"
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn extension_point_without_provider_is_rejected_by_plan() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let group = common::unique_slug("epn-grp");
|
||||
let child = common::unique_slug("epn-child");
|
||||
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Group declares an EP `ghost` with NO default body (body-less).
|
||||
let dir = manifest_dir();
|
||||
let gmanifest =
|
||||
format!("[group]\nslug = \"{group}\"\nname = \"EPN\"\n\nextension_points = [\"ghost\"]\n");
|
||||
let gpath = dir.path().join("group.toml");
|
||||
fs::write(&gpath, &gmanifest).unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&gpath)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// App under the group provides no `ghost` → any plan for it is refused.
|
||||
let _child = AppGuard::new(&env.url, &env.token, &child);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &child, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
let amanifest = format!("[app]\nslug = \"{child}\"\nname = \"EPN child\"\n");
|
||||
let apath = dir.path().join("picloud.toml");
|
||||
fs::write(&apath, &amanifest).unwrap();
|
||||
let out = common::pic_as(&env)
|
||||
.args(["plan", "--file"])
|
||||
.arg(&apath)
|
||||
.output()
|
||||
.expect("plan");
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"an inherited extension point with no provider must fail plan"
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).to_lowercase();
|
||||
assert!(
|
||||
stderr.contains("ghost") || stderr.contains("no provider") || stderr.contains("extension"),
|
||||
"plan error should name the unprovided extension point:\n{stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Id of the named script in a group (`pic scripts ls --group <g>`).
|
||||
fn group_script_id(env: &common::TestEnv, group: &str, name: &str) -> String {
|
||||
named_id(env, &["scripts", "ls", "--group", group], name)
|
||||
}
|
||||
|
||||
/// Id of the named script in an app (`pic scripts ls --app <a>`).
|
||||
fn app_script_id(env: &common::TestEnv, app: &str, name: &str) -> String {
|
||||
named_id(env, &["scripts", "ls", "--app", app], name)
|
||||
}
|
||||
|
||||
fn named_id(env: &common::TestEnv, args: &[&str], name: &str) -> String {
|
||||
let ls = common::pic_as(env).args(args).output().expect("scripts ls");
|
||||
let table = String::from_utf8(ls.stdout).unwrap();
|
||||
table
|
||||
.lines()
|
||||
.map(common::cells)
|
||||
.find(|c| c.get(2) == Some(&name))
|
||||
.and_then(|c| c.first().map(|s| (*s).to_string()))
|
||||
.unwrap_or_else(|| panic!("script `{name}` not found:\n{table}"))
|
||||
}
|
||||
|
||||
fn invoke_body(env: &common::TestEnv, id: &str) -> serde_json::Value {
|
||||
let out = common::pic_as(env)
|
||||
.args(["scripts", "invoke", id])
|
||||
.output()
|
||||
.expect("scripts invoke");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"invoke failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
serde_json::from_slice(&out.stdout).expect("invoke body is JSON")
|
||||
}
|
||||
@@ -137,6 +137,37 @@ fn group_module_is_lexically_sealed_under_inheritance() {
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn reserved_group_module_name_is_rejected() {
|
||||
// Parity with app modules (api.rs): a group `module` named after a built-in
|
||||
// SDK namespace is refused at create — not silently accepted to fail later.
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let group = common::unique_slug("gm-rsv");
|
||||
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let dir = manifest_dir();
|
||||
fs::write(dir.path().join("scripts/kv.rhai"), r#"fn x() { 1 }"#).unwrap();
|
||||
let out = common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/kv.rhai"))
|
||||
.args(["--group", &group, "--name", "kv", "--kind", "module"])
|
||||
.output()
|
||||
.expect("deploy");
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"a reserved module name must be rejected for a group module too"
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn dangling_import_is_rejected_by_plan() {
|
||||
|
||||
@@ -19,22 +19,23 @@ use picloud_manager_core::{
|
||||
AppDomainRepository, AppMembersRepository, AppMembersState, AppRepository, ApplyService,
|
||||
AppsState, AuthState, AuthzRepo, DeadLetterRepo, DeadLettersState, DevEmailState, Dispatcher,
|
||||
DocsServiceImpl, EmailInboundState, EmailServiceImpl, FilesAdminState, FilesConfig,
|
||||
FilesServiceImpl, FsFilesRepo, GroupMembersRepository, GroupRepository, GroupsState,
|
||||
HttpConfig, HttpServiceImpl, InboundNonceDedup, KvAdminState, KvServiceImpl,
|
||||
OutboxEventEmitter, OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository,
|
||||
PostgresAdminUserRepository, PostgresApiKeyRepository, PostgresAppDomainRepository,
|
||||
PostgresAppMembersRepository, PostgresAppRepository, PostgresAppSecretsRepo,
|
||||
PostgresAppUserInvitationRepo, PostgresAppUserPasswordResetRepo, PostgresAppUserRepository,
|
||||
PostgresAppUserRoleRepo, PostgresAppUserSessionRepository, PostgresAppUserVerificationRepo,
|
||||
PostgresDeadLetterRepo, PostgresDeadLetterService, PostgresDocsRepo,
|
||||
PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresGroupMembersRepository,
|
||||
PostgresGroupRepository, PostgresKvRepo, PostgresOutboxRepo, PostgresPubsubRepo,
|
||||
PostgresRouteRepository, PostgresScriptRepository, PostgresSecretsRepo, PostgresTopicRepo,
|
||||
PostgresTriggerRepo, PostgresVarsRepo, PrincipalResolver, PubsubServiceImpl,
|
||||
RealtimeAuthorityImpl, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling,
|
||||
ScriptRepository, SecretsConfig, SecretsServiceImpl, SecretsState, SubscriberTokenConfig,
|
||||
TopicRepo, TopicsState, TriggerConfig, TriggerRepo, TriggersState, UsersServiceConfig,
|
||||
UsersServiceImpl, VarsApiState, VarsServiceImpl,
|
||||
FilesServiceImpl, FsFilesRepo, FsGroupFilesRepo, GroupDocsServiceImpl, GroupFilesServiceImpl,
|
||||
GroupKvServiceImpl, GroupMembersRepository, GroupRepository, GroupsState, HttpConfig,
|
||||
HttpServiceImpl, InboundNonceDedup, KvAdminState, KvServiceImpl, OutboxEventEmitter,
|
||||
OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository,
|
||||
PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository,
|
||||
PostgresAppRepository, PostgresAppSecretsRepo, PostgresAppUserInvitationRepo,
|
||||
PostgresAppUserPasswordResetRepo, PostgresAppUserRepository, PostgresAppUserRoleRepo,
|
||||
PostgresAppUserSessionRepository, PostgresAppUserVerificationRepo, PostgresDeadLetterRepo,
|
||||
PostgresDeadLetterService, PostgresDocsRepo, PostgresExecutionLogRepository,
|
||||
PostgresExecutionLogSink, PostgresGroupCollectionResolver, PostgresGroupDocsRepo,
|
||||
PostgresGroupKvRepo, PostgresGroupMembersRepository, PostgresGroupRepository, PostgresKvRepo,
|
||||
PostgresOutboxRepo, PostgresPubsubRepo, PostgresRouteRepository, PostgresScriptRepository,
|
||||
PostgresSecretsRepo, PostgresTopicRepo, PostgresTriggerRepo, PostgresVarsRepo,
|
||||
PrincipalResolver, PubsubServiceImpl, RealtimeAuthorityImpl, RepoResolver, RouteAdminState,
|
||||
RouteRepository, SandboxCeiling, ScriptRepository, SecretsConfig, SecretsServiceImpl,
|
||||
SecretsState, SubscriberTokenConfig, TopicRepo, TopicsState, TriggerConfig, TriggerRepo,
|
||||
TriggersState, UsersServiceConfig, UsersServiceImpl, VarsApiState, VarsServiceImpl,
|
||||
};
|
||||
use picloud_orchestrator_core::realtime::DEFAULT_GC_INTERVAL_SECS;
|
||||
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
|
||||
@@ -158,6 +159,23 @@ pub async fn build_app(
|
||||
events.clone(),
|
||||
picloud_manager_core::kv_service::kv_max_value_bytes_from_env(),
|
||||
));
|
||||
// §11.6 shared group collections (KV): a separate store keyed by the owning
|
||||
// group, resolved from cx.app_id's chain. Reuses the KV value-size cap.
|
||||
let group_kv: Arc<dyn picloud_shared::GroupKvService> =
|
||||
Arc::new(GroupKvServiceImpl::with_max_value_bytes(
|
||||
Arc::new(PostgresGroupKvRepo::new(pool.clone())),
|
||||
Arc::new(PostgresGroupCollectionResolver::new(pool.clone())),
|
||||
authz.clone(),
|
||||
picloud_manager_core::kv_service::kv_max_value_bytes_from_env(),
|
||||
));
|
||||
// §11.6 shared group collections (docs): group-keyed group_docs store.
|
||||
let group_docs: Arc<dyn picloud_shared::GroupDocsService> =
|
||||
Arc::new(GroupDocsServiceImpl::with_max_value_bytes(
|
||||
Arc::new(PostgresGroupDocsRepo::new(pool.clone())),
|
||||
Arc::new(PostgresGroupCollectionResolver::new(pool.clone())),
|
||||
authz.clone(),
|
||||
picloud_manager_core::docs_service::docs_max_value_bytes_from_env(),
|
||||
));
|
||||
let docs: Arc<dyn DocsService> = Arc::new(DocsServiceImpl::with_max_value_bytes(
|
||||
docs_repo,
|
||||
authz.clone(),
|
||||
@@ -196,6 +214,16 @@ pub async fn build_app(
|
||||
events.clone(),
|
||||
files_max_size,
|
||||
));
|
||||
// §11.6 shared group collections (files): group-keyed blobs under
|
||||
// `<root>/files/groups/<group_id>/...`, resolved from cx.app_id's chain.
|
||||
// Shares the per-file size cap; no events (no app to attribute a trigger to).
|
||||
let group_files: Arc<dyn picloud_shared::GroupFilesService> =
|
||||
Arc::new(GroupFilesServiceImpl::new(
|
||||
Arc::new(FsGroupFilesRepo::new(pool.clone(), files_root.clone())),
|
||||
Arc::new(PostgresGroupCollectionResolver::new(pool.clone())),
|
||||
authz.clone(),
|
||||
files_max_size,
|
||||
));
|
||||
// v1.1.6 realtime: the in-process broadcaster is shared between the
|
||||
// publish path (PubsubServiceImpl fans out to SSE subscribers after
|
||||
// the durable outbox fan-out) and the SSE endpoint (subscribe side).
|
||||
@@ -340,7 +368,10 @@ pub async fn build_app(
|
||||
queue,
|
||||
invoke,
|
||||
vars,
|
||||
);
|
||||
)
|
||||
.with_group_kv(group_kv)
|
||||
.with_group_docs(group_docs)
|
||||
.with_group_files(group_files);
|
||||
// v1.1.9: keep the invoke depth bound aligned with the dispatcher's
|
||||
// trigger-depth bound (same counter under the hood).
|
||||
let engine_limits = Limits {
|
||||
|
||||
181
crates/shared/src/group_docs.rs
Normal file
181
crates/shared/src/group_docs.rs
Normal file
@@ -0,0 +1,181 @@
|
||||
//! `GroupDocsService` — the §11.6 shared group-collection DOCS contract.
|
||||
//!
|
||||
//! The cross-app-sharing counterpart to [`crate::DocsService`], analogous to
|
||||
//! [`crate::GroupKvService`]. A script reaches it via the explicit
|
||||
//! `docs::shared_collection("name")` handle. 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
|
||||
//! shared with `kind='docs'`. That ancestry walk is the isolation boundary —
|
||||
//! the trait surface takes **no** group id (owner derivation stays in the impl).
|
||||
//!
|
||||
//! Trust model (§11.6): **reads are open** to any subtree script (anonymous
|
||||
//! public HTTP included); **writes require an authenticated principal** with
|
||||
//! editor+ on the owning group.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{DocId, DocRow, DocsListPage, SdkCallCx};
|
||||
|
||||
#[async_trait]
|
||||
pub trait GroupDocsService: Send + Sync {
|
||||
async fn create(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
data: serde_json::Value,
|
||||
) -> Result<DocId, GroupDocsError>;
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
id: DocId,
|
||||
) -> Result<Option<DocRow>, GroupDocsError>;
|
||||
|
||||
async fn find(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
filter: serde_json::Value,
|
||||
) -> Result<Vec<DocRow>, GroupDocsError>;
|
||||
|
||||
async fn find_one(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
filter: serde_json::Value,
|
||||
) -> Result<Option<DocRow>, GroupDocsError>;
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
id: DocId,
|
||||
data: serde_json::Value,
|
||||
) -> Result<(), GroupDocsError>;
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
id: DocId,
|
||||
) -> Result<bool, GroupDocsError>;
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<DocsListPage, GroupDocsError>;
|
||||
}
|
||||
|
||||
/// Stub for test bundles that don't exercise shared docs collections.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct NoopGroupDocsService;
|
||||
|
||||
#[async_trait]
|
||||
impl GroupDocsService for NoopGroupDocsService {
|
||||
async fn create(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_collection: &str,
|
||||
_data: serde_json::Value,
|
||||
) -> Result<DocId, GroupDocsError> {
|
||||
Err(GroupDocsError::Backend("group docs is not wired in".into()))
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_collection: &str,
|
||||
_id: DocId,
|
||||
) -> Result<Option<DocRow>, GroupDocsError> {
|
||||
Err(GroupDocsError::Backend("group docs is not wired in".into()))
|
||||
}
|
||||
|
||||
async fn find(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_collection: &str,
|
||||
_filter: serde_json::Value,
|
||||
) -> Result<Vec<DocRow>, GroupDocsError> {
|
||||
Err(GroupDocsError::Backend("group docs is not wired in".into()))
|
||||
}
|
||||
|
||||
async fn find_one(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_collection: &str,
|
||||
_filter: serde_json::Value,
|
||||
) -> Result<Option<DocRow>, GroupDocsError> {
|
||||
Err(GroupDocsError::Backend("group docs is not wired in".into()))
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_collection: &str,
|
||||
_id: DocId,
|
||||
_data: serde_json::Value,
|
||||
) -> Result<(), GroupDocsError> {
|
||||
Err(GroupDocsError::Backend("group docs is not wired in".into()))
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_collection: &str,
|
||||
_id: DocId,
|
||||
) -> Result<bool, GroupDocsError> {
|
||||
Err(GroupDocsError::Backend("group docs is not wired in".into()))
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_collection: &str,
|
||||
_cursor: Option<&str>,
|
||||
_limit: u32,
|
||||
) -> Result<DocsListPage, GroupDocsError> {
|
||||
Err(GroupDocsError::Backend("group docs is not wired in".into()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Failure modes surfaced to the Rhai bridge. Mirrors [`crate::DocsError`] plus
|
||||
/// the §11.6 `CollectionNotShared` boundary.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum GroupDocsError {
|
||||
#[error("collection name must not be empty")]
|
||||
InvalidCollection,
|
||||
|
||||
/// No group on the calling app's ancestor chain declares this collection
|
||||
/// shared with `kind='docs'` — the structural "not shared with you"
|
||||
/// boundary (also the outcome for a foreign app).
|
||||
#[error("collection {0:?} is not a shared group docs collection for this app")]
|
||||
CollectionNotShared(String),
|
||||
|
||||
#[error("document data must be a JSON object")]
|
||||
InvalidData,
|
||||
|
||||
#[error("invalid filter: {0}")]
|
||||
InvalidFilter(String),
|
||||
|
||||
#[error("unsupported operator: {0}")]
|
||||
UnsupportedOperator(String),
|
||||
|
||||
#[error("document not found")]
|
||||
NotFound,
|
||||
|
||||
/// 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).
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
|
||||
#[error("group docs: data too large ({actual} bytes; limit {limit})")]
|
||||
ValueTooLarge { limit: usize, actual: usize },
|
||||
|
||||
#[error("group docs backend error: {0}")]
|
||||
Backend(String),
|
||||
}
|
||||
203
crates/shared/src/group_files.rs
Normal file
203
crates/shared/src/group_files.rs
Normal file
@@ -0,0 +1,203 @@
|
||||
//! `GroupFilesService` — the §11.6 shared group-collection FILES contract.
|
||||
//!
|
||||
//! The cross-app-sharing counterpart to [`crate::FilesService`], analogous to
|
||||
//! [`crate::GroupKvService`] / [`crate::GroupDocsService`]. A script reaches it
|
||||
//! via the explicit `files::shared_collection("name")` handle. 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 shared with `kind='files'`. That ancestry walk is the isolation
|
||||
//! boundary — the trait surface takes **no** group id (owner derivation stays in
|
||||
//! the impl).
|
||||
//!
|
||||
//! Trust model (§11.6): **reads are open** to any subtree script (anonymous
|
||||
//! public HTTP included); **writes require an authenticated principal** with
|
||||
//! editor+ on the owning group. Same shape as group KV/docs.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{FileMeta, FileUpdate, FilesListPage, NewFile, SdkCallCx};
|
||||
|
||||
#[async_trait]
|
||||
pub trait GroupFilesService: Send + Sync {
|
||||
async fn create(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
new: NewFile,
|
||||
) -> Result<Uuid, GroupFilesError>;
|
||||
|
||||
async fn head(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
id: &str,
|
||||
) -> Result<Option<FileMeta>, GroupFilesError>;
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
id: &str,
|
||||
) -> Result<Option<Vec<u8>>, GroupFilesError>;
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
id: &str,
|
||||
upd: FileUpdate,
|
||||
) -> Result<(), GroupFilesError>;
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
id: &str,
|
||||
) -> Result<bool, GroupFilesError>;
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<FilesListPage, GroupFilesError>;
|
||||
}
|
||||
|
||||
/// Stub for test bundles that don't exercise shared files collections.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct NoopGroupFilesService;
|
||||
|
||||
#[async_trait]
|
||||
impl GroupFilesService for NoopGroupFilesService {
|
||||
async fn create(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_collection: &str,
|
||||
_new: NewFile,
|
||||
) -> Result<Uuid, GroupFilesError> {
|
||||
Err(GroupFilesError::Backend(
|
||||
"group files is not wired in".into(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn head(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_collection: &str,
|
||||
_id: &str,
|
||||
) -> Result<Option<FileMeta>, GroupFilesError> {
|
||||
Err(GroupFilesError::Backend(
|
||||
"group files is not wired in".into(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_collection: &str,
|
||||
_id: &str,
|
||||
) -> Result<Option<Vec<u8>>, GroupFilesError> {
|
||||
Err(GroupFilesError::Backend(
|
||||
"group files is not wired in".into(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_collection: &str,
|
||||
_id: &str,
|
||||
_upd: FileUpdate,
|
||||
) -> Result<(), GroupFilesError> {
|
||||
Err(GroupFilesError::Backend(
|
||||
"group files is not wired in".into(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_collection: &str,
|
||||
_id: &str,
|
||||
) -> Result<bool, GroupFilesError> {
|
||||
Err(GroupFilesError::Backend(
|
||||
"group files is not wired in".into(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_collection: &str,
|
||||
_cursor: Option<&str>,
|
||||
_limit: u32,
|
||||
) -> Result<FilesListPage, GroupFilesError> {
|
||||
Err(GroupFilesError::Backend(
|
||||
"group files is not wired in".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Failure modes surfaced to the Rhai bridge. Mirrors [`crate::FilesError`] plus
|
||||
/// the §11.6 `CollectionNotShared` boundary.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum GroupFilesError {
|
||||
#[error("invalid collection name: {0}")]
|
||||
InvalidCollection(String),
|
||||
|
||||
/// No group on the calling app's ancestor chain declares this collection
|
||||
/// shared with `kind='files'` — the structural "not shared with you"
|
||||
/// boundary (also the outcome for a foreign app).
|
||||
#[error("collection {0:?} is not a shared group files collection for this app")]
|
||||
CollectionNotShared(String),
|
||||
|
||||
#[error("missing required field: {0}")]
|
||||
MissingField(&'static str),
|
||||
|
||||
#[error("file too large: {size} bytes exceeds limit of {limit} bytes")]
|
||||
TooLarge { size: usize, limit: usize },
|
||||
|
||||
#[error("file name too long: {0} bytes exceeds 255")]
|
||||
NameTooLong(usize),
|
||||
|
||||
#[error("content_type too long: {0} bytes exceeds 127")]
|
||||
ContentTypeTooLong(usize),
|
||||
|
||||
#[error("file not found")]
|
||||
NotFound,
|
||||
|
||||
#[error("file content corrupted (checksum mismatch)")]
|
||||
Corrupted,
|
||||
|
||||
/// 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).
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
|
||||
#[error("group files backend error: {0}")]
|
||||
Backend(String),
|
||||
}
|
||||
|
||||
/// Map the shared [`crate::FilesError`] — returned by `validate_collection`
|
||||
/// (re-exported as `validate_files_collection`) and the `NewFile`/`FileUpdate`
|
||||
/// validators that the group-files service reuses — onto the group error. Lives
|
||||
/// here (not in manager-core) so the orphan rule is satisfied.
|
||||
impl From<crate::FilesError> for GroupFilesError {
|
||||
fn from(e: crate::FilesError) -> Self {
|
||||
use crate::FilesError as F;
|
||||
match e {
|
||||
F::InvalidCollection(c) => Self::InvalidCollection(c),
|
||||
F::MissingField(f) => Self::MissingField(f),
|
||||
F::TooLarge { size, limit } => Self::TooLarge { size, limit },
|
||||
F::NameTooLong(n) => Self::NameTooLong(n),
|
||||
F::ContentTypeTooLong(n) => Self::ContentTypeTooLong(n),
|
||||
F::NotFound => Self::NotFound,
|
||||
F::Corrupted => Self::Corrupted,
|
||||
F::Forbidden => Self::Forbidden,
|
||||
F::Backend(s) => Self::Backend(s),
|
||||
}
|
||||
}
|
||||
}
|
||||
141
crates/shared/src/group_kv.rs
Normal file
141
crates/shared/src/group_kv.rs
Normal 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_collection("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),
|
||||
}
|
||||
@@ -16,6 +16,9 @@ pub mod exec_summary;
|
||||
pub mod execution_log;
|
||||
pub mod files;
|
||||
pub mod group;
|
||||
pub mod group_docs;
|
||||
pub mod group_files;
|
||||
pub mod group_kv;
|
||||
pub mod http;
|
||||
pub mod ids;
|
||||
pub mod inbox;
|
||||
@@ -65,6 +68,9 @@ pub use files::{
|
||||
SAFE_RENDER_FALLBACK,
|
||||
};
|
||||
pub use group::Group;
|
||||
pub use group_docs::{GroupDocsError, GroupDocsService, NoopGroupDocsService};
|
||||
pub use group_files::{GroupFilesError, GroupFilesService, NoopGroupFilesService};
|
||||
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,
|
||||
@@ -76,7 +82,9 @@ pub use inbox::{
|
||||
pub use invoke::{InvokeError, InvokeService, InvokeTarget, NoopInvokeService, ResolvedScript};
|
||||
pub use kv::{KvError, KvListPage, KvService, NoopKvService};
|
||||
pub use log_sink::{ExecutionLogSink, LogSinkError};
|
||||
pub use modules::{ModuleScript, ModuleSource, ModuleSourceError, NoopModuleSource};
|
||||
pub use modules::{
|
||||
ModuleResolution, ModuleScript, ModuleSource, ModuleSourceError, NoopModuleSource,
|
||||
};
|
||||
pub use outbox_writer::{HttpDispatchPayload, NewHttpOutbox, OutboxWriter, OutboxWriterError};
|
||||
pub use pubsub::{
|
||||
topic_matches, validate_topic_pattern, NoopPubsubService, PubsubError, PubsubService,
|
||||
|
||||
@@ -62,6 +62,19 @@ impl ModuleScript {
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of [`ModuleSource::resolve_policy`] — the §5.5 lexical-vs-dynamic
|
||||
/// decision the resolver acts on.
|
||||
#[derive(Debug)]
|
||||
pub enum ModuleResolution {
|
||||
/// Bind this module (lexical, or an extension point's resolved provider).
|
||||
Module(ModuleScript),
|
||||
/// The name is an extension point, but no provider exists for the
|
||||
/// inheriting app (no app override and no default body) — a hard error.
|
||||
NoProvider,
|
||||
/// No declaration of the name anywhere on the chain → module-not-found.
|
||||
NotFound,
|
||||
}
|
||||
|
||||
/// Lookup contract used by `PicloudModuleResolver`. `resolve` walks the
|
||||
/// module set up the chain rooted at `origin` (the importing script's
|
||||
/// defining node), nearest-owner-wins. The `origin` is trusted
|
||||
@@ -80,6 +93,28 @@ pub trait ModuleSource: Send + Sync {
|
||||
origin: ScriptOwner,
|
||||
name: &str,
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError>;
|
||||
|
||||
/// §5.5 extension-point-aware resolution. Decides lexical vs dynamic by
|
||||
/// the **nearest declaration** of `name` walking up `origin`'s chain: a
|
||||
/// concrete module → lexical (`resolve(origin, …)`); an extension-point
|
||||
/// marker → dynamic, resolved against the **inheriting app**
|
||||
/// (`resolve(App(inheriting_app), …)`) so the app can override, falling
|
||||
/// back to the default body up-chain; the EP wins on a depth tie.
|
||||
///
|
||||
/// The default impl is lexical-only (no extension points) — the Postgres
|
||||
/// source overrides it. Test fakes that don't exercise EPs inherit this.
|
||||
async fn resolve_policy(
|
||||
&self,
|
||||
origin: ScriptOwner,
|
||||
inheriting_app: AppId,
|
||||
name: &str,
|
||||
) -> Result<ModuleResolution, ModuleSourceError> {
|
||||
let _ = inheriting_app;
|
||||
Ok(match self.resolve(origin, name).await? {
|
||||
Some(m) => ModuleResolution::Module(m),
|
||||
None => ModuleResolution::NotFound,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Failure modes surfaced from `ModuleSource::resolve`. "Not found" is
|
||||
|
||||
@@ -20,12 +20,13 @@
|
||||
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, GroupDocsService,
|
||||
GroupFilesService, GroupKvService, HttpService, InvokeService, KvService, ModuleSource,
|
||||
NoopDeadLetterService, NoopDocsService, NoopEmailService, NoopEventEmitter, NoopFilesService,
|
||||
NoopGroupDocsService, NoopGroupFilesService, 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 +115,24 @@ 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_collection(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>,
|
||||
|
||||
/// Shared cross-app group DOCS collections (§11.6). Scripts get
|
||||
/// `docs::shared_collection(name)`. Wired via [`Services::with_group_docs`];
|
||||
/// defaults to `NoopGroupDocsService`.
|
||||
pub group_docs: Arc<dyn GroupDocsService>,
|
||||
|
||||
/// Shared cross-app group FILES collections (§11.6). Scripts get
|
||||
/// `files::shared_collection(name)`. Wired via [`Services::with_group_files`];
|
||||
/// defaults to `NoopGroupFilesService`.
|
||||
pub group_files: Arc<dyn GroupFilesService>,
|
||||
}
|
||||
|
||||
impl Services {
|
||||
@@ -153,9 +172,37 @@ 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),
|
||||
group_docs: Arc::new(NoopGroupDocsService),
|
||||
group_files: Arc::new(NoopGroupFilesService),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the §11.6 shared group-KV 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
|
||||
}
|
||||
|
||||
/// Set the §11.6 shared group-DOCS service (picloud binary; tests leave noop).
|
||||
#[must_use]
|
||||
pub fn with_group_docs(mut self, group_docs: Arc<dyn GroupDocsService>) -> Self {
|
||||
self.group_docs = group_docs;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the §11.6 shared group-FILES service (picloud binary; tests leave noop).
|
||||
#[must_use]
|
||||
pub fn with_group_files(mut self, group_files: Arc<dyn GroupFilesService>) -> Self {
|
||||
self.group_files = group_files;
|
||||
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
|
||||
|
||||
@@ -552,9 +552,21 @@ trust inversion). The rule:
|
||||
> site; the `PicloudModuleResolver` reads the importing node from Rhai's `_source` (set to each module's
|
||||
> owner via `AST::set_source(encode(owner))` before `eval_ast_as_new` — the lexical chaining), and the
|
||||
> cache is re-keyed by resolved `ScriptId`. Group modules + group-script imports are now allowed
|
||||
> (`group_scripts_api`), and the single-node apply runs the §5.5 dangling-import `plan` check. **Opt-in
|
||||
> extension points** (the dynamic-resolution branch + `[extension_points]` manifest declaration) remain
|
||||
> the one deferred piece of §5.5 — a clean additive follow-up on top of the now-origin-aware resolver.
|
||||
> (`group_scripts_api`), and the single-node apply runs the §5.5 dangling-import `plan` check.
|
||||
|
||||
> **Resolved (extension points ✅) — §5.5 is complete.** Opt-in polymorphism shipped: an extension
|
||||
> point is a **pure marker** `(owner, name)` (migration 0051 `extension_points`, owner-polymorphic +
|
||||
> CASCADE — structurally a `secrets` name), authored declaratively as an `[app]`/`[group]` manifest key
|
||||
> `extension_points = [...]` (a *node key*, so TOML can't mis-nest it); the optional **default body is a
|
||||
> co-located `kind=module` script**. The resolver's `ModuleSource::resolve_policy(origin, app, name)`
|
||||
> applies **nearest-declaration-kind-wins** over the importing node's chain (one query: min EP depth vs
|
||||
> min module depth, EP wins a tie): a concrete module → lexical; an EP marker → **dynamic**, resolved
|
||||
> against the inheriting app (its override, else the default body up-chain) — `NoProvider` is a hard
|
||||
> error. Reconcile mirrors `secrets` (name-only diff/prune/state-token); the single-node apply adds the
|
||||
> **no-provider `plan` check** (every EP visible to an app must have a provider); read-only
|
||||
> `pic extension-points ls` + `pull` round-trip complete the surface. **Authoring is declarative only**
|
||||
> (no imperative `add/rm`). Verified e2e: a group default resolves for an inheriting app, and the app
|
||||
> can **override** it — the deliberate inverse of the Phase 4b sealed/lexical import.
|
||||
|
||||
### 5.6 Tree lifecycle: delete, reparent, rename
|
||||
|
||||
@@ -1032,6 +1044,39 @@ Resolved items now live inline next to their topic. What genuinely remains:
|
||||
real shared-scope authz model. Optionally, trigger/route **templates** (§4.5) if cardinality
|
||||
demands.
|
||||
|
||||
> **Shipped — §11.6 KV + DOCS + FILES slices (full shared read/write).** A group declares a
|
||||
> collection group-shared (`[group]` manifest `collections = [...]`, owner-polymorphic marker table
|
||||
> `0052_group_collections` with a `kind` discriminator); the data lives in a per-kind store keyed by
|
||||
> the owning `group_id` (NOT app — a shared row belongs to the group): `0053_group_kv_entries`
|
||||
> (`kind='kv'`), `0054_group_docs` (`kind='docs'`, the queryable-JSON store), and `0055_group_files`
|
||||
> (`kind='files'`, the blob store — metadata in Postgres, bytes on disk under
|
||||
> `<root>/files/groups/<group_id>/...`, a `groups/` infix disjoint from the per-app `files/<app_id>/`
|
||||
> subtree so the existing recursive orphan sweeper covers both). Scripts read/write via the
|
||||
> **explicit** `kv::shared_collection("catalog")` / `docs::shared_collection("articles")` /
|
||||
> `files::shared_collection("assets")` handles (distinct `GroupKvHandle`/`GroupDocsHandle`/
|
||||
> `GroupFilesHandle`; `shared` alone is a Rhai reserved word, hence `shared_collection`). The service
|
||||
> resolves the owning group from `cx.app_id`'s ancestor chain **filtered by kind**, nearest-group-wins
|
||||
> — **that walk is the isolation boundary**: a foreign app's chain never contains the owning group, so
|
||||
> the name returns `CollectionNotShared`; a `kv`, a `docs`, and a `files` collection of the same name
|
||||
> are distinct stores. **Trust model:** reads are open to any subtree script (anonymous public HTTP
|
||||
> included — the declaration *is* the grant); **writes require an authenticated editor+** on the
|
||||
> owning group (`GroupKvWrite`/`GroupDocsWrite`/`GroupFilesWrite`, fail closed on an anonymous
|
||||
> principal). The docs slice reuses the `docs_filter` DSL — `build_find_query` was generalized on its
|
||||
> owner column (`docs`/`app_id` vs `group_docs`/`group_id`), both compile-time literals, so the find
|
||||
> SQL has one source; the files slice likewise generalized the atomic-write + checksum-on-read path
|
||||
> helpers on an owner-relative dir, so the security-sensitive disk mechanics have one source.
|
||||
> Declarative authoring uses the **string-or-table** form: `collections = ["catalog", { name =
|
||||
> "articles", kind = "docs" }, { name = "assets", kind = "files" }]` (bare string = `kv`). CASCADE on
|
||||
> group delete; an app delete leaves the shared data. Live- + journey-validated for all three kinds
|
||||
> (app A writes, app B reads/finds/gets the same bytes; a sibling-subtree app gets
|
||||
> `CollectionNotShared`).
|
||||
>
|
||||
> **Deferred (documented gaps):** write-triggers/events on shared collections (the "group trigger
|
||||
> has no app to watch" problem); **topics/queue** shared collections (trigger-centric, same gap);
|
||||
> per-group total-size quotas + write-rate limits; CAS/`set_if`; an operator admin API for shared
|
||||
> blobs (scripts use the SDK; `pic collections ls` shows the marker — matches KV/docs); app-declared
|
||||
> collections. Multi-node tree-apply leans on the runtime backstop for no-op edges, as elsewhere.
|
||||
|
||||
### 11.1 Re-sequencing review (post-Phase-3)
|
||||
|
||||
A review after Phase 3 shipped surfaced three adjustments to the 4→6 plan; carried here as decisions
|
||||
|
||||
@@ -74,6 +74,44 @@ collection, id)` for docs. Collections are **mandatory**, not optional
|
||||
— even single-collection apps name their collection. The service layer
|
||||
rejects requests with empty collection names.
|
||||
|
||||
### Shared group collections (§11.6)
|
||||
|
||||
`kv::collection(name)` is **app-private** — scoped to `cx.app_id`, the
|
||||
isolation boundary. A separate, explicit handle reaches a **group-shared**
|
||||
KV collection that every app in a group's subtree reads (and an
|
||||
authenticated editor+ writes):
|
||||
|
||||
```rhai
|
||||
let cat = kv::shared_collection("catalog"); // resolves the nearest
|
||||
cat.set("sku-1", #{ price: 9 }); // ancestor group that
|
||||
let p = cat.get("sku-1"); // declares "catalog"
|
||||
```
|
||||
|
||||
The name is deliberately distinct from `kv::collection` — a script opts
|
||||
into shared scope explicitly (implicit shadowing would silently redirect a
|
||||
write across apps). (`kv::shared` would be ideal but `shared` is a Rhai
|
||||
reserved word, hence `shared_collection`.) The owning group is resolved
|
||||
from `cx.app_id`'s ancestor chain — that walk is the isolation boundary;
|
||||
an app outside the owning group's subtree gets a "not a shared collection"
|
||||
error, never another subtree's data. A collection is made shared
|
||||
declaratively (`[group]` manifest `collections = [...]`), never from a
|
||||
script. **Reads** are open to any subtree script (including anonymous
|
||||
public endpoints — declaring the collection *is* the grant); **writes**
|
||||
require an authenticated principal with editor+ on the owning group.
|
||||
|
||||
**Docs and files too.** The same handle exists for the queryable-JSON store
|
||||
and the blob store: `docs::shared_collection("articles")` returns a shared-docs
|
||||
handle with the full `create`/`get`/`find`/`find_one`/`update`/`delete`/`list`
|
||||
surface, and `files::shared_collection("assets")` returns a shared-files handle
|
||||
with `create`/`head`/`get`/`update`/`delete`/`list` (Blob in/out) — both with
|
||||
the same trust model + isolation boundary as the KV one. A `[group]` declares a
|
||||
collection's store with a kind: `collections = ["catalog", { name =
|
||||
"articles", kind = "docs" }, { name = "assets", kind = "files" }]` — a bare
|
||||
string is `kv`, a `{ name, kind }` table sets it (`kv`/`docs`/`files`). A `kv`,
|
||||
a `docs`, and a `files` collection of the same name are distinct stores; group
|
||||
blobs live on disk under `<root>/files/groups/<group_id>/...`. (Topics/queue
|
||||
shared collections are not yet implemented.)
|
||||
|
||||
## Error convention
|
||||
|
||||
- **Throw on failure.** `widgets.set("k", "v")` throws a Rhai runtime
|
||||
|
||||
Reference in New Issue
Block a user