feat: add authorized Plugin Ticket data capabilities
This commit is contained in:
@@ -25,7 +25,7 @@ pub use model::{
|
|||||||
AuthRef, ModelCapability, ModelManifest, ReasoningControl, ReasoningEffort, SchemeKind,
|
AuthRef, ModelCapability, ModelManifest, ReasoningControl, ReasoningEffort, SchemeKind,
|
||||||
};
|
};
|
||||||
pub use paths::user_profiles_path;
|
pub use paths::user_profiles_path;
|
||||||
pub use plugin_profile::{PluginProfileConfig, PluginProfileSelection};
|
pub use plugin_profile::{PluginProfileConfig, PluginProfileSelection, PluginTicketDataGrant};
|
||||||
pub use profile::{
|
pub use profile::{
|
||||||
ProfileDiscovery, ProfileError, ProfileExecutionTarget, ProfileManifestSnapshot,
|
ProfileDiscovery, ProfileError, ProfileExecutionTarget, ProfileManifestSnapshot,
|
||||||
ProfileMetadata, ProfileRegistry, ProfileRegistryEntry, ProfileRegistrySource,
|
ProfileMetadata, ProfileRegistry, ProfileRegistryEntry, ProfileRegistrySource,
|
||||||
|
|||||||
@@ -136,10 +136,32 @@ impl fmt::Display for PluginSurface {
|
|||||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||||
pub enum PluginRequestedGrant {
|
pub enum PluginRequestedGrant {
|
||||||
Logging,
|
Logging,
|
||||||
Https { slot: String },
|
Https {
|
||||||
|
slot: String,
|
||||||
|
},
|
||||||
|
TicketRead,
|
||||||
|
TicketDataRead {
|
||||||
|
collection: String,
|
||||||
|
schema_id: String,
|
||||||
|
schema_version: String,
|
||||||
|
},
|
||||||
|
TicketDataWrite {
|
||||||
|
collection: String,
|
||||||
|
schema_id: String,
|
||||||
|
schema_version: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A slot requests HTTPS protocol bounds, not credentials or concrete bindings.
|
/// A slot requests HTTPS protocol bounds, not credentials or concrete bindings.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct PluginDataCollection {
|
||||||
|
pub collection: String,
|
||||||
|
pub schema_id: String,
|
||||||
|
pub schema_version: String,
|
||||||
|
pub schema: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct PluginConnectionSlot {
|
pub struct PluginConnectionSlot {
|
||||||
@@ -240,6 +262,8 @@ pub struct PluginPackageManifest {
|
|||||||
pub connections: Vec<PluginConnectionSlot>,
|
pub connections: Vec<PluginConnectionSlot>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub requested_grants: Vec<PluginRequestedGrant>,
|
pub requested_grants: Vec<PluginRequestedGrant>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub data_collections: Vec<PluginDataCollection>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PluginPackageManifest {
|
impl PluginPackageManifest {
|
||||||
@@ -248,6 +272,8 @@ impl PluginPackageManifest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub const PLUGIN_COMPONENT_TICKET_TOOL_WORLD: &str = "yoi:plugin/ticket-tool-plugin@2.1.0";
|
||||||
|
|
||||||
pub const PLUGIN_RUNTIME_COMPONENT_KIND: &str = "wasm-component";
|
pub const PLUGIN_RUNTIME_COMPONENT_KIND: &str = "wasm-component";
|
||||||
pub const PLUGIN_COMPONENT_TOOL_WORLD: &str = "yoi:plugin/tool-plugin@2.0.0";
|
pub const PLUGIN_COMPONENT_TOOL_WORLD: &str = "yoi:plugin/tool-plugin@2.0.0";
|
||||||
pub const PLUGIN_COMPONENT_HTTPS_TOOL_WORLD: &str = "yoi:plugin/https-tool-plugin@2.0.0";
|
pub const PLUGIN_COMPONENT_HTTPS_TOOL_WORLD: &str = "yoi:plugin/https-tool-plugin@2.0.0";
|
||||||
@@ -1088,6 +1114,39 @@ fn validate_manifest(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let ticket_world = runtime.world == PLUGIN_COMPONENT_TICKET_TOOL_WORLD;
|
||||||
|
if !ticket_world
|
||||||
|
&& (!manifest.data_collections.is_empty()
|
||||||
|
|| manifest.requested_grants.iter().any(|g| {
|
||||||
|
matches!(
|
||||||
|
g,
|
||||||
|
PluginRequestedGrant::TicketRead
|
||||||
|
| PluginRequestedGrant::TicketDataRead { .. }
|
||||||
|
| PluginRequestedGrant::TicketDataWrite { .. }
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
{
|
||||||
|
return Err(reject(
|
||||||
|
PluginDiagnosticKind::Api,
|
||||||
|
"Ticket capabilities require the exact @2.1.0 Ticket world".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut collections = BTreeSet::new();
|
||||||
|
for c in &manifest.data_collections {
|
||||||
|
if [&c.collection, &c.schema_id, &c.schema_version]
|
||||||
|
.iter()
|
||||||
|
.any(|s| s.is_empty() || s.len() > 128 || s.chars().any(char::is_control))
|
||||||
|
|| !collections.insert((&c.collection, &c.schema_id, &c.schema_version))
|
||||||
|
{
|
||||||
|
return Err(reject(
|
||||||
|
PluginDiagnosticKind::Malformed,
|
||||||
|
"invalid or duplicate data collection".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
schema::validate_data_schema(&c.schema)
|
||||||
|
.map_err(|e| reject(PluginDiagnosticKind::Malformed, e))?;
|
||||||
|
}
|
||||||
|
let mut ticket_grants = BTreeSet::new();
|
||||||
let mut requested_slots = BTreeSet::new();
|
let mut requested_slots = BTreeSet::new();
|
||||||
let mut logging = false;
|
let mut logging = false;
|
||||||
for grant in &manifest.requested_grants {
|
for grant in &manifest.requested_grants {
|
||||||
@@ -1095,6 +1154,29 @@ fn validate_manifest(
|
|||||||
PluginRequestedGrant::Logging if !logging => logging = true,
|
PluginRequestedGrant::Logging if !logging => logging = true,
|
||||||
PluginRequestedGrant::Https { slot }
|
PluginRequestedGrant::Https { slot }
|
||||||
if slots.contains(slot) && requested_slots.insert(slot) => {}
|
if slots.contains(slot) && requested_slots.insert(slot) => {}
|
||||||
|
PluginRequestedGrant::TicketRead
|
||||||
|
if ticket_world && ticket_grants.insert(("read", "", "", "")) => {}
|
||||||
|
PluginRequestedGrant::TicketDataRead {
|
||||||
|
collection,
|
||||||
|
schema_id,
|
||||||
|
schema_version,
|
||||||
|
}
|
||||||
|
| PluginRequestedGrant::TicketDataWrite {
|
||||||
|
collection,
|
||||||
|
schema_id,
|
||||||
|
schema_version,
|
||||||
|
} if ticket_world
|
||||||
|
&& collections.contains(&(collection, schema_id, schema_version))
|
||||||
|
&& ticket_grants.insert((
|
||||||
|
if matches!(grant, PluginRequestedGrant::TicketDataWrite { .. }) {
|
||||||
|
"data-write"
|
||||||
|
} else {
|
||||||
|
"data-read"
|
||||||
|
},
|
||||||
|
collection.as_str(),
|
||||||
|
schema_id.as_str(),
|
||||||
|
schema_version.as_str(),
|
||||||
|
)) => {}
|
||||||
_ => {
|
_ => {
|
||||||
return Err(reject(
|
return Err(reject(
|
||||||
PluginDiagnosticKind::Malformed,
|
PluginDiagnosticKind::Malformed,
|
||||||
@@ -1121,7 +1203,10 @@ fn validate_manifest(
|
|||||||
.required_imports
|
.required_imports
|
||||||
.iter()
|
.iter()
|
||||||
.any(|v| v == "yoi:host/logging@2.0.0");
|
.any(|v| v == "yoi:host/logging@2.0.0");
|
||||||
if http != !slots.is_empty() || http != connections || logging != logs {
|
if (!ticket_world && (http != !slots.is_empty() || logging != logs))
|
||||||
|
|| (ticket_world && ((!slots.is_empty() && !http) || (logging && !logs)))
|
||||||
|
|| http != connections
|
||||||
|
{
|
||||||
return Err(reject(
|
return Err(reject(
|
||||||
PluginDiagnosticKind::Api,
|
PluginDiagnosticKind::Api,
|
||||||
"Host imports and requested grants/slots disagree".into(),
|
"Host imports and requested grants/slots disagree".into(),
|
||||||
@@ -1970,3 +2055,40 @@ output_schema = { type = "object", additionalProperties = false }
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod ticket_manifest_tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn old_world_cannot_request_ticket_capabilities_or_schema_data() {
|
||||||
|
let mut manifest: PluginPackageManifest = toml::from_str(include_str!(
|
||||||
|
"../../../resources/plugin/templates/rust-component-tool/plugin.toml"
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let files = BTreeMap::from([(manifest.runtime.component.clone(), Vec::new())]);
|
||||||
|
manifest
|
||||||
|
.requested_grants
|
||||||
|
.push(PluginRequestedGrant::TicketRead);
|
||||||
|
let archive = StoredArchive { files };
|
||||||
|
let error = validate_manifest(&manifest, &archive, "fixture", PluginSourceKind::Explicit)
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(error.kind, PluginDiagnosticKind::Api);
|
||||||
|
manifest
|
||||||
|
.requested_grants
|
||||||
|
.retain(|g| !matches!(g, PluginRequestedGrant::TicketRead));
|
||||||
|
manifest.data_collections.push(PluginDataCollection {
|
||||||
|
collection: "notes".into(),
|
||||||
|
schema_id: "note".into(),
|
||||||
|
schema_version: "1".into(),
|
||||||
|
schema: json!({"type":"string"}),
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
validate_manifest(&manifest, &archive, "fixture", PluginSourceKind::Explicit)
|
||||||
|
.unwrap_err()
|
||||||
|
.kind,
|
||||||
|
PluginDiagnosticKind::Api
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ pub fn validate_component(
|
|||||||
"yoi:plugin/https-tool-plugin@2.0.0" => "https-tool-plugin",
|
"yoi:plugin/https-tool-plugin@2.0.0" => "https-tool-plugin",
|
||||||
"yoi:plugin/logged-tool-plugin@2.0.0" => "logged-tool-plugin",
|
"yoi:plugin/logged-tool-plugin@2.0.0" => "logged-tool-plugin",
|
||||||
"yoi:plugin/https-logged-tool-plugin@2.0.0" => "https-logged-tool-plugin",
|
"yoi:plugin/https-logged-tool-plugin@2.0.0" => "https-logged-tool-plugin",
|
||||||
|
"yoi:plugin/ticket-tool-plugin@2.1.0" => "ticket-tool-plugin",
|
||||||
_ => return Err(format!("unsupported Component world `{world}`")),
|
_ => return Err(format!("unsupported Component world `{world}`")),
|
||||||
};
|
};
|
||||||
if !wasmparser::Parser::is_component(bytes) {
|
if !wasmparser::Parser::is_component(bytes) {
|
||||||
@@ -107,9 +108,27 @@ fn contract(world_name: &str) -> Result<(Resolve, WorldId), String> {
|
|||||||
resolve
|
resolve
|
||||||
.push_str("yoi-host-v2.wit", HOST_WIT)
|
.push_str("yoi-host-v2.wit", HOST_WIT)
|
||||||
.map_err(|error| format!("invalid embedded Host WIT: {error:#}"))?;
|
.map_err(|error| format!("invalid embedded Host WIT: {error:#}"))?;
|
||||||
let package = resolve
|
let old_package = resolve
|
||||||
.push_str("yoi-plugin-v2.wit", PLUGIN_WIT)
|
.push_str("yoi-plugin-v2.wit", PLUGIN_WIT)
|
||||||
.map_err(|error| format!("invalid embedded Plugin WIT: {error:#}"))?;
|
.map_err(|error| format!("invalid embedded Plugin WIT: {error:#}"))?;
|
||||||
|
let package = if world_name == "ticket-tool-plugin" {
|
||||||
|
resolve
|
||||||
|
.push_str(
|
||||||
|
"yoi-host-v2.1.wit",
|
||||||
|
include_str!(
|
||||||
|
"../../../../resources/plugin/wit-ticket/deps/yoi-host-v2.1/yoi-host-v2.1.wit"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("invalid Ticket Host WIT: {e:#}"))?;
|
||||||
|
resolve
|
||||||
|
.push_str(
|
||||||
|
"yoi-plugin-v2.1.wit",
|
||||||
|
include_str!("../../../../resources/plugin/wit-ticket/yoi-plugin-v2.1.wit"),
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("invalid Ticket Plugin WIT: {e:#}"))?
|
||||||
|
} else {
|
||||||
|
old_package
|
||||||
|
};
|
||||||
let world = resolve
|
let world = resolve
|
||||||
.select_world(&[package], Some(world_name))
|
.select_world(&[package], Some(world_name))
|
||||||
.map_err(|error| format!("invalid embedded Plugin world: {error:#}"))?;
|
.map_err(|error| format!("invalid embedded Plugin world: {error:#}"))?;
|
||||||
@@ -570,3 +589,57 @@ mod tests {
|
|||||||
assert!(validate_component(&component, PURE, &[]).is_err());
|
assert!(validate_component(&component, PURE, &[]).is_err());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod ticket_tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn additive_ticket_world_cannot_be_claimed_as_an_old_component_world() {
|
||||||
|
let (resolve, world) = contract("ticket-tool-plugin").unwrap();
|
||||||
|
let mut core =
|
||||||
|
wit_component::dummy_module(&resolve, world, wit_parser::ManglingAndAbi::Standard32);
|
||||||
|
wit_component::embed_component_metadata(
|
||||||
|
&mut core,
|
||||||
|
&resolve,
|
||||||
|
world,
|
||||||
|
wit_component::StringEncoding::UTF8,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let bytes = wit_component::ComponentEncoder::default()
|
||||||
|
.module(&core)
|
||||||
|
.unwrap()
|
||||||
|
.validate(true)
|
||||||
|
.encode()
|
||||||
|
.unwrap();
|
||||||
|
let imports: Vec<_> = surface(&resolve, world, true)
|
||||||
|
.unwrap()
|
||||||
|
.into_keys()
|
||||||
|
.collect();
|
||||||
|
let checked = validate_component(
|
||||||
|
&bytes,
|
||||||
|
crate::plugin::PLUGIN_COMPONENT_TICKET_TOOL_WORLD,
|
||||||
|
&imports,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(checked.imports.contains(&"yoi:host/objects@2.1.0".into()));
|
||||||
|
assert!(
|
||||||
|
checked
|
||||||
|
.imports
|
||||||
|
.contains(&"yoi:host/ticket-read@2.1.0".into())
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
checked
|
||||||
|
.imports
|
||||||
|
.contains(&"yoi:host/extension-data@2.1.0".into())
|
||||||
|
);
|
||||||
|
assert_eq!(checked.exports, ["yoi:plugin/tool@2.0.0"]);
|
||||||
|
assert!(
|
||||||
|
validate_component(
|
||||||
|
&bytes,
|
||||||
|
crate::plugin::PLUGIN_COMPONENT_HTTPS_LOGGED_TOOL_WORLD,
|
||||||
|
&imports
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -508,3 +508,147 @@ mod tests {
|
|||||||
assert!(error.contains("depth limit"), "{error}");
|
assert!(error.contains("depth limit"), "{error}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extension cells require a closed typed tree, not a permissive Tool schema.
|
||||||
|
/// Restricting composition/ref keywords here prevents an untyped branch or a
|
||||||
|
/// boolean schema from admitting undeclared fields. New schema shapes require
|
||||||
|
/// deliberate admission changes, never remote retrieval or guest migration.
|
||||||
|
pub fn validate_data_schema(schema: &Value) -> Result<(), String> {
|
||||||
|
validate_tool_schema(schema)?;
|
||||||
|
fn closed(node: &Value) -> Result<(), String> {
|
||||||
|
let object = node
|
||||||
|
.as_object()
|
||||||
|
.ok_or("data schema must be a typed object")?;
|
||||||
|
let kind = object
|
||||||
|
.get("type")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.ok_or("data schema requires one explicit type")?;
|
||||||
|
let allowed: &[&str] = match kind {
|
||||||
|
"object" => &[
|
||||||
|
"type",
|
||||||
|
"description",
|
||||||
|
"properties",
|
||||||
|
"required",
|
||||||
|
"additionalProperties",
|
||||||
|
],
|
||||||
|
"array" => &[
|
||||||
|
"type",
|
||||||
|
"description",
|
||||||
|
"items",
|
||||||
|
"minItems",
|
||||||
|
"maxItems",
|
||||||
|
"uniqueItems",
|
||||||
|
],
|
||||||
|
"string" => &[
|
||||||
|
"type",
|
||||||
|
"description",
|
||||||
|
"enum",
|
||||||
|
"minLength",
|
||||||
|
"maxLength",
|
||||||
|
"pattern",
|
||||||
|
],
|
||||||
|
"number" | "integer" => &[
|
||||||
|
"type",
|
||||||
|
"description",
|
||||||
|
"enum",
|
||||||
|
"minimum",
|
||||||
|
"maximum",
|
||||||
|
"exclusiveMinimum",
|
||||||
|
"exclusiveMaximum",
|
||||||
|
"multipleOf",
|
||||||
|
],
|
||||||
|
"boolean" | "null" => &["type", "description", "enum"],
|
||||||
|
_ => return Err("unsupported data schema type".into()),
|
||||||
|
};
|
||||||
|
if object.keys().any(|key| !allowed.contains(&key.as_str())) {
|
||||||
|
return Err("unsupported data schema keyword".into());
|
||||||
|
}
|
||||||
|
match kind {
|
||||||
|
"object" => {
|
||||||
|
if object.get("additionalProperties") != Some(&Value::Bool(false)) {
|
||||||
|
return Err("data objects must be closed".into());
|
||||||
|
}
|
||||||
|
let properties = object
|
||||||
|
.get("properties")
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.ok_or("data object requires properties")?;
|
||||||
|
for value in properties.values() {
|
||||||
|
closed(value)?;
|
||||||
|
}
|
||||||
|
if let Some(required) = object.get("required").and_then(Value::as_array) {
|
||||||
|
if required
|
||||||
|
.iter()
|
||||||
|
.any(|name| name.as_str().is_none_or(|n| !properties.contains_key(n)))
|
||||||
|
{
|
||||||
|
return Err("required data field is undeclared".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"array" => closed(
|
||||||
|
object
|
||||||
|
.get("items")
|
||||||
|
.ok_or("data arrays require typed items")?,
|
||||||
|
)?,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
closed(schema)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Payload validation uses the same offline closed contract as package admission.
|
||||||
|
pub fn validate_data_value(schema: &Value, value: &Value) -> Result<(), String> {
|
||||||
|
validate_data_schema(schema)?;
|
||||||
|
if serde_json::to_vec(value)
|
||||||
|
.map_err(|_| "invalid data value")?
|
||||||
|
.len()
|
||||||
|
> 64 * 1024
|
||||||
|
{
|
||||||
|
return Err("data value exceeds 64 KiB".into());
|
||||||
|
}
|
||||||
|
let validator = jsonschema::options()
|
||||||
|
.with_draft(jsonschema::Draft::Draft202012)
|
||||||
|
.with_retriever(NoRetrieval)
|
||||||
|
.build(schema)
|
||||||
|
.map_err(|_| "invalid data schema")?;
|
||||||
|
if !validator.is_valid(value) {
|
||||||
|
return Err("data value does not match declared schema".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod data_tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
#[test]
|
||||||
|
fn closed_data_schema_rejects_open_or_untyped_branches() {
|
||||||
|
for schema in [
|
||||||
|
json!(true),
|
||||||
|
json!({}),
|
||||||
|
json!({"type":"object"}),
|
||||||
|
json!({"type":"object","properties":{"nested":{}},"additionalProperties":false}),
|
||||||
|
json!({"type":"array"}),
|
||||||
|
json!({"type":"array","items":true}),
|
||||||
|
json!({"type":"object","properties":{},"additionalProperties":false,"anyOf":[{}]}),
|
||||||
|
json!({"type":"object","properties":{},"additionalProperties":false,"required":["undeclared"]}),
|
||||||
|
] {
|
||||||
|
assert!(validate_data_schema(&schema).is_err(), "{schema}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn closed_data_value_enforces_nested_types_and_size() {
|
||||||
|
let schema = json!({"type":"object","additionalProperties":false,"required":["notes"],"properties":{"notes":{"type":"array","items":{"type":"object","properties":{"text":{"type":"string","maxLength":8}},"required":["text"],"additionalProperties":false}}}});
|
||||||
|
validate_data_schema(&schema).unwrap();
|
||||||
|
validate_data_value(&schema, &json!({"notes":[{"text":"ok"}]})).unwrap();
|
||||||
|
for value in [
|
||||||
|
json!({"notes":[{"text":"ok","extra":true}]}),
|
||||||
|
json!({"notes":[{"text":42}]}),
|
||||||
|
json!({"notes":[{"text":"too long text"}]}),
|
||||||
|
json!({"extra":true}),
|
||||||
|
] {
|
||||||
|
assert!(validate_data_value(&schema, &value).is_err());
|
||||||
|
}
|
||||||
|
assert!(validate_data_value(&json!({"type":"string"}), &json!("x".repeat(65536))).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,10 +40,24 @@ pub struct PluginProfileSelection {
|
|||||||
pub logging: bool,
|
pub logging: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub https_slots: Vec<String>,
|
pub https_slots: Vec<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||||
|
pub ticket_read: bool,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub ticket_data_read: Vec<PluginTicketDataGrant>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub ticket_data_write: Vec<PluginTicketDataGrant>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub config: BTreeMap<String, serde_json::Value>,
|
pub config: BTreeMap<String, serde_json::Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct PluginTicketDataGrant {
|
||||||
|
pub collection: String,
|
||||||
|
pub schema_id: String,
|
||||||
|
pub schema_version: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -12,6 +12,24 @@ pub const HTTPS_TOOL_WORLD: &str = "yoi:plugin/https-tool-plugin@2.0.0";
|
|||||||
pub const TOOL_WIT: &str = include_str!("../../../resources/plugin/wit/yoi-plugin-v2.wit");
|
pub const TOOL_WIT: &str = include_str!("../../../resources/plugin/wit/yoi-plugin-v2.wit");
|
||||||
pub const HOST_WIT: &str =
|
pub const HOST_WIT: &str =
|
||||||
include_str!("../../../resources/plugin/wit/deps/yoi-host/yoi-host-v2.wit");
|
include_str!("../../../resources/plugin/wit/deps/yoi-host/yoi-host-v2.wit");
|
||||||
|
/// Additive Ticket resource world. Existing world constants retain exact v2 ABI.
|
||||||
|
pub const TICKET_TOOL_WORLD: &str = "yoi:plugin/ticket-tool-plugin@2.1.0";
|
||||||
|
pub const TICKET_TOOL_WIT: &str =
|
||||||
|
include_str!("../../../resources/plugin/wit-ticket/yoi-plugin-v2.1.wit");
|
||||||
|
pub const TICKET_HOST_WIT: &str =
|
||||||
|
include_str!("../../../resources/plugin/wit-ticket/deps/yoi-host-v2.1/yoi-host-v2.1.wit");
|
||||||
|
pub const MAX_EXTENSION_DATA_BYTES: usize = 64 * 1024;
|
||||||
|
|
||||||
|
/// Encode a cell without implicit mutation retries or schema conversion.
|
||||||
|
/// The Host remains authoritative for closed-schema and namespace validation.
|
||||||
|
pub fn extension_data_json<T: Serialize>(value: &T) -> Result<String> {
|
||||||
|
let json = serde_json::to_string(value).map_err(ToolError::serialization)?;
|
||||||
|
if json.len() > MAX_EXTENSION_DATA_BYTES {
|
||||||
|
return Err(ToolError::invalid_input("extension data exceeds 64 KiB"));
|
||||||
|
}
|
||||||
|
Ok(json)
|
||||||
|
}
|
||||||
|
|
||||||
pub const MAX_TOOL_OUTPUT_BYTES: usize = 64 * 1024;
|
pub const MAX_TOOL_OUTPUT_BYTES: usize = 64 * 1024;
|
||||||
pub const MAX_TOOL_INPUT_BYTES: usize = 64 * 1024;
|
pub const MAX_TOOL_INPUT_BYTES: usize = 64 * 1024;
|
||||||
pub const MAX_CONFIG_BYTES: usize = 64 * 1024;
|
pub const MAX_CONFIG_BYTES: usize = 64 * 1024;
|
||||||
@@ -213,13 +231,17 @@ pub fn run_json_tool<I: DeserializeOwned>(
|
|||||||
#[macro_export]
|
#[macro_export]
|
||||||
macro_rules! export_component_tool {
|
macro_rules! export_component_tool {
|
||||||
($adapter:ident, $handler:path) => {
|
($adapter:ident, $handler:path) => {
|
||||||
|
$crate::export_component_tool!($adapter, $handler, exports::yoi::plugin::tool);
|
||||||
|
};
|
||||||
|
($adapter:ident, $handler:path, $interface:path) => {
|
||||||
|
use $interface as __yoi_component_tool_interface;
|
||||||
struct $adapter;
|
struct $adapter;
|
||||||
impl exports::yoi::plugin::tool::Guest for $adapter {
|
impl __yoi_component_tool_interface::Guest for $adapter {
|
||||||
fn invoke(
|
fn invoke(
|
||||||
input: exports::yoi::plugin::tool::Invocation,
|
input: __yoi_component_tool_interface::Invocation,
|
||||||
) -> ::std::result::Result<
|
) -> ::std::result::Result<
|
||||||
exports::yoi::plugin::tool::Output,
|
__yoi_component_tool_interface::Output,
|
||||||
exports::yoi::plugin::tool::ToolError,
|
__yoi_component_tool_interface::ToolError,
|
||||||
> {
|
> {
|
||||||
let context = $crate::ToolContext::new(
|
let context = $crate::ToolContext::new(
|
||||||
input.invocation_id,
|
input.invocation_id,
|
||||||
@@ -230,13 +252,13 @@ macro_rules! export_component_tool {
|
|||||||
$crate::run_json_tool(context, &input.input_json, $handler)
|
$crate::run_json_tool(context, &input.input_json, $handler)
|
||||||
.map(|output| {
|
.map(|output| {
|
||||||
let (summary, content, structured_json) = output.into_parts();
|
let (summary, content, structured_json) = output.into_parts();
|
||||||
exports::yoi::plugin::tool::Output {
|
__yoi_component_tool_interface::Output {
|
||||||
summary,
|
summary,
|
||||||
content,
|
content,
|
||||||
structured_json,
|
structured_json,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.map_err(|error| exports::yoi::plugin::tool::ToolError {
|
.map_err(|error| __yoi_component_tool_interface::ToolError {
|
||||||
code: error.code_str().to_owned(),
|
code: error.code_str().to_owned(),
|
||||||
message: error.message().to_owned(),
|
message: error.message().to_owned(),
|
||||||
})
|
})
|
||||||
@@ -246,6 +268,16 @@ macro_rules! export_component_tool {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Export glue for the exact Ticket world, retaining the original Tool ABI.
|
||||||
|
/// Generate bindings from wit-ticket with generate_all; its versioned modules
|
||||||
|
/// expose yoi::host2_1_0::{objects,ticket_read,extension_data} without raw ABI code.
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! export_ticket_component_tool {
|
||||||
|
($adapter:ident, $handler:path) => {
|
||||||
|
$crate::export_component_tool!($adapter, $handler, exports::yoi::plugin2_0_0::tool);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,47 @@
|
|||||||
|
//! Compile-pass: the additive world uses the original Tool export and typed
|
||||||
|
//! imported Ticket resources; no handwritten canonical ABI or ambient authority.
|
||||||
|
use yoi_plugin_pdk::wit_bindgen;
|
||||||
|
wit_bindgen::generate!({
|
||||||
|
world: "ticket-tool-plugin",
|
||||||
|
path: "../../resources/plugin/wit-ticket",
|
||||||
|
generate_all,
|
||||||
|
runtime_path: "yoi_plugin_pdk::wit_bindgen::rt",
|
||||||
|
});
|
||||||
|
fn handle(
|
||||||
|
_: yoi_plugin_pdk::ToolContext,
|
||||||
|
input: String,
|
||||||
|
) -> yoi_plugin_pdk::Result<yoi_plugin_pdk::ToolOutput> {
|
||||||
|
yoi_plugin_pdk::ToolOutput::summary(input)
|
||||||
|
}
|
||||||
|
yoi_plugin_pdk::export_ticket_component_tool!(TicketProbe, handle);
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn additive_export_uses_original_tool_result_abi() {
|
||||||
|
use exports::yoi::plugin2_0_0::tool::{Guest, Invocation};
|
||||||
|
let result = TicketProbe::invoke(Invocation {
|
||||||
|
invocation_id: "invocation".into(),
|
||||||
|
tool_name: "probe".into(),
|
||||||
|
input_json: "\"ok\"".into(),
|
||||||
|
config_json: "{}".into(),
|
||||||
|
remaining_ms: 1000,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result.summary, "ok");
|
||||||
|
let _access = yoi::host2_1_0::objects::Access::ExtensionWrite;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn old_wit_dependencies_are_exact_copies_not_augmented_versions() {
|
||||||
|
assert_eq!(
|
||||||
|
include_str!("../../../resources/plugin/wit/yoi-plugin-v2.wit"),
|
||||||
|
include_str!("../../../resources/plugin/wit-ticket/deps/yoi-plugin-v2/yoi-plugin-v2.wit")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
include_str!("../../../resources/plugin/wit/deps/yoi-host/yoi-host-v2.wit"),
|
||||||
|
include_str!("../../../resources/plugin/wit-ticket/deps/yoi-host-v2/yoi-host-v2.wit")
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
yoi_plugin_pdk::TICKET_TOOL_WORLD,
|
||||||
|
yoi_plugin_pdk::TOOL_WORLD
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -91,12 +91,25 @@ pub enum PluginSurface {
|
|||||||
Tool,
|
Tool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Only capabilities implemented by the initial Tool Host are representable.
|
/// Exact grant identities; new operations are never implied by old pins.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
|
||||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||||
pub enum PluginGrant {
|
pub enum PluginGrant {
|
||||||
Logging,
|
Logging,
|
||||||
Https { slot: String },
|
Https {
|
||||||
|
slot: String,
|
||||||
|
},
|
||||||
|
TicketRead,
|
||||||
|
TicketDataRead {
|
||||||
|
collection: String,
|
||||||
|
schema_id: String,
|
||||||
|
schema_version: String,
|
||||||
|
},
|
||||||
|
TicketDataWrite {
|
||||||
|
collection: String,
|
||||||
|
schema_id: String,
|
||||||
|
schema_version: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
@@ -164,6 +177,7 @@ impl WorkerPluginPin {
|
|||||||
| plugin::PLUGIN_COMPONENT_HTTPS_TOOL_WORLD
|
| plugin::PLUGIN_COMPONENT_HTTPS_TOOL_WORLD
|
||||||
| plugin::PLUGIN_COMPONENT_LOGGED_TOOL_WORLD
|
| plugin::PLUGIN_COMPONENT_LOGGED_TOOL_WORLD
|
||||||
| plugin::PLUGIN_COMPONENT_HTTPS_LOGGED_TOOL_WORLD
|
| plugin::PLUGIN_COMPONENT_HTTPS_LOGGED_TOOL_WORLD
|
||||||
|
| plugin::PLUGIN_COMPONENT_TICKET_TOOL_WORLD
|
||||||
)
|
)
|
||||||
|| !valid_digest(&artifact.config_schema.digest)
|
|| !valid_digest(&artifact.config_schema.digest)
|
||||||
|| !valid_identity(&artifact.package_id)
|
|| !valid_identity(&artifact.package_id)
|
||||||
@@ -201,6 +215,27 @@ impl WorkerPluginPin {
|
|||||||
.any(|slot| *slot == binding.slot)
|
.any(|slot| *slot == binding.slot)
|
||||||
})
|
})
|
||||||
|| projection.grants.iter().any(|grant| match grant {
|
|| projection.grants.iter().any(|grant| match grant {
|
||||||
|
PluginGrant::TicketRead
|
||||||
|
| PluginGrant::TicketDataRead { .. }
|
||||||
|
| PluginGrant::TicketDataWrite { .. } => {
|
||||||
|
artifact.world != plugin::PLUGIN_COMPONENT_TICKET_TOOL_WORLD
|
||||||
|
|| !artifact.requested_grants.contains(grant)
|
||||||
|
|| match grant {
|
||||||
|
PluginGrant::TicketDataRead {
|
||||||
|
collection,
|
||||||
|
schema_id,
|
||||||
|
schema_version,
|
||||||
|
}
|
||||||
|
| PluginGrant::TicketDataWrite {
|
||||||
|
collection,
|
||||||
|
schema_id,
|
||||||
|
schema_version,
|
||||||
|
} => [collection, schema_id, schema_version]
|
||||||
|
.iter()
|
||||||
|
.any(|v| !valid_identity(v) || v.len() > 128),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
PluginGrant::Logging => !artifact.requested_grants.contains(&PluginGrant::Logging),
|
PluginGrant::Logging => !artifact.requested_grants.contains(&PluginGrant::Logging),
|
||||||
PluginGrant::Https { slot } => {
|
PluginGrant::Https { slot } => {
|
||||||
!valid_identity(slot)
|
!valid_identity(slot)
|
||||||
|
|||||||
@@ -202,3 +202,39 @@ fn deserialized_unknown_fields_versions_and_noncanonical_order_are_rejected() {
|
|||||||
unsorted.input.plugins.push(second);
|
unsorted.input.plugins.push(second);
|
||||||
assert_eq!(unsorted.validate_canonical(), Err(PlanError::Invalid));
|
assert_eq!(unsorted.validate_canonical(), Err(PlanError::Invalid));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ticket_grants_require_exact_world_request_and_schema_namespace() {
|
||||||
|
let mut a = artifact();
|
||||||
|
let write = PluginGrant::TicketDataWrite {
|
||||||
|
collection: "notes".into(),
|
||||||
|
schema_id: "note".into(),
|
||||||
|
schema_version: "1".into(),
|
||||||
|
};
|
||||||
|
let read = PluginGrant::TicketDataRead {
|
||||||
|
collection: "notes".into(),
|
||||||
|
schema_id: "note".into(),
|
||||||
|
schema_version: "1".into(),
|
||||||
|
};
|
||||||
|
a.requested_grants
|
||||||
|
.extend([PluginGrant::TicketRead, write.clone()]);
|
||||||
|
let mut selected = projection();
|
||||||
|
selected.grants.push(write.clone());
|
||||||
|
// Even a fabricated metadata request cannot add Ticket operations to v2.0.
|
||||||
|
assert_eq!(pin(&a, selected.clone()), Err(PlanError::Invalid));
|
||||||
|
a.world = plugin::PLUGIN_COMPONENT_TICKET_TOOL_WORLD.into();
|
||||||
|
let exact = pin(&a, selected.clone()).unwrap();
|
||||||
|
assert!(exact.projection().grants.contains(&write));
|
||||||
|
assert!(!exact.projection().grants.contains(&read)); // write is not read
|
||||||
|
selected.grants.push(read);
|
||||||
|
assert_eq!(pin(&a, selected), Err(PlanError::Invalid));
|
||||||
|
let mut selected = projection();
|
||||||
|
selected.grants.push(PluginGrant::TicketDataWrite {
|
||||||
|
collection: "notes".into(),
|
||||||
|
schema_id: "note".into(),
|
||||||
|
schema_version: "2".into(),
|
||||||
|
});
|
||||||
|
assert_eq!(pin(&a, selected), Err(PlanError::Invalid));
|
||||||
|
a.requested_grants.retain(|g| g != &write);
|
||||||
|
assert_eq!(exact.matches_artifact(&a), Err(PlanError::Invalid));
|
||||||
|
}
|
||||||
|
|||||||
@@ -360,6 +360,7 @@ impl ArtifactCache {
|
|||||||
|| !same_json(&d.tools, &m.tools)?
|
|| !same_json(&d.tools, &m.tools)?
|
||||||
|| !same_json(&d.connections, &m.connections)?
|
|| !same_json(&d.connections, &m.connections)?
|
||||||
|| !same_json(&d.requested_grants, &m.requested_grants)?
|
|| !same_json(&d.requested_grants, &m.requested_grants)?
|
||||||
|
|| !same_json(&d.data_collections, &m.data_collections)?
|
||||||
|| d.config_schema.dcdl != m.config_schema
|
|| d.config_schema.dcdl != m.config_schema
|
||||||
|| d.config_schema.digest != schema.digest
|
|| d.config_schema.digest != schema.digest
|
||||||
|| d.config_schema.identity != schema.identity
|
|| d.config_schema.identity != schema.identity
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ fn fixture() -> (CanonicalPluginArtifact, PluginArtifact) {
|
|||||||
tools: serde_json::from_value(serde_json::to_value(m.tools).unwrap()).unwrap(),
|
tools: serde_json::from_value(serde_json::to_value(m.tools).unwrap()).unwrap(),
|
||||||
connections: vec![],
|
connections: vec![],
|
||||||
requested_grants: vec![],
|
requested_grants: vec![],
|
||||||
|
data_collections: vec![],
|
||||||
config_schema: server_api::PluginSchemaContribution {
|
config_schema: server_api::PluginSchemaContribution {
|
||||||
identity: format!("{}/config", m.id),
|
identity: format!("{}/config", m.id),
|
||||||
version: m.version.clone(),
|
version: m.version.clone(),
|
||||||
@@ -391,6 +392,7 @@ async fn canonical_package_must_match_complete_descriptor_not_only_digests() {
|
|||||||
"tools",
|
"tools",
|
||||||
"connections",
|
"connections",
|
||||||
"grants",
|
"grants",
|
||||||
|
"data_collections",
|
||||||
"schema_identity",
|
"schema_identity",
|
||||||
"schema_version",
|
"schema_version",
|
||||||
"schema_digest",
|
"schema_digest",
|
||||||
@@ -415,6 +417,14 @@ async fn canonical_package_must_match_complete_descriptor_not_only_digests() {
|
|||||||
"grants" => d
|
"grants" => d
|
||||||
.requested_grants
|
.requested_grants
|
||||||
.push(server_api::PluginGrantMetadata::Logging),
|
.push(server_api::PluginGrantMetadata::Logging),
|
||||||
|
"data_collections" => d
|
||||||
|
.data_collections
|
||||||
|
.push(server_api::PluginDataSchemaMetadata {
|
||||||
|
collection: "notes".into(),
|
||||||
|
schema_id: "note".into(),
|
||||||
|
schema_version: "1".into(),
|
||||||
|
schema: serde_json::json!({"type":"string"}),
|
||||||
|
}),
|
||||||
"schema_identity" => d.config_schema.identity.push('x'),
|
"schema_identity" => d.config_schema.identity.push('x'),
|
||||||
"schema_version" => d.config_schema.version.push('x'),
|
"schema_version" => d.config_schema.version.push('x'),
|
||||||
"schema_digest" => d.config_schema.digest.push('x'),
|
"schema_digest" => d.config_schema.digest.push('x'),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
mod ticket;
|
||||||
use crate::sandbox::SandboxLimits;
|
use crate::sandbox::SandboxLimits;
|
||||||
use crate::{
|
use crate::{
|
||||||
bindings,
|
bindings,
|
||||||
@@ -297,6 +298,15 @@ impl Executor {
|
|||||||
|s| s,
|
|s| s,
|
||||||
)
|
)
|
||||||
.map_err(trap)?;
|
.map_err(trap)?;
|
||||||
|
if compiled.manifest.runtime.world == plugin::PLUGIN_COMPONENT_TICKET_TOOL_WORLD {
|
||||||
|
use crate::host::{extension_data, objects, ticket_read};
|
||||||
|
objects::add_to_linker::<_, wasmtime::component::HasSelf<_>>(&mut linker, |s| s)
|
||||||
|
.map_err(trap)?;
|
||||||
|
ticket_read::add_to_linker::<_, wasmtime::component::HasSelf<_>>(&mut linker, |s| s)
|
||||||
|
.map_err(trap)?;
|
||||||
|
extension_data::add_to_linker::<_, wasmtime::component::HasSelf<_>>(&mut linker, |s| s)
|
||||||
|
.map_err(trap)?;
|
||||||
|
}
|
||||||
let instance = bindings::HttpsLoggedToolPlugin::instantiate_async(
|
let instance = bindings::HttpsLoggedToolPlugin::instantiate_async(
|
||||||
&mut store,
|
&mut store,
|
||||||
&compiled.component,
|
&compiled.component,
|
||||||
|
|||||||
@@ -0,0 +1,455 @@
|
|||||||
|
use super::*;
|
||||||
|
use crate::host::{
|
||||||
|
Ticket, TicketDataOperation as Op, TicketDataResult as Reply, extension_data as data, objects,
|
||||||
|
ticket_read,
|
||||||
|
};
|
||||||
|
use server_api::{
|
||||||
|
PluginDataAddress as Address, PluginDataCell as Cell, PluginDataCollection as Collection,
|
||||||
|
PluginDataExpectation as Expectation, PluginDataMutation as Mutation,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn denied() -> errors::HostError {
|
||||||
|
host::error(errors::Code::Denied, "Ticket capability not granted")
|
||||||
|
}
|
||||||
|
fn invalid() -> errors::HostError {
|
||||||
|
host::error(errors::Code::InvalidInput, "invalid Ticket data request")
|
||||||
|
}
|
||||||
|
fn protocol() -> errors::HostError {
|
||||||
|
host::error(errors::Code::Unavailable, "invalid Ticket owner response")
|
||||||
|
}
|
||||||
|
fn collection(c: data::Collection) -> Collection {
|
||||||
|
Collection {
|
||||||
|
collection: c.collection,
|
||||||
|
schema_id: c.schema_id,
|
||||||
|
schema_version: c.schema_version,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn address(a: data::Address) -> Address {
|
||||||
|
Address {
|
||||||
|
collection: collection(a.namespace),
|
||||||
|
key: a.key,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn cell(c: Cell) -> data::Cell {
|
||||||
|
match c {
|
||||||
|
Cell::NeverWritten => data::Cell::NeverWritten,
|
||||||
|
Cell::Deleted { last_delete } => data::Cell::Deleted(last_delete),
|
||||||
|
Cell::Present {
|
||||||
|
value_json,
|
||||||
|
last_write,
|
||||||
|
} => data::Cell::Present(data::PresentCell {
|
||||||
|
value_json,
|
||||||
|
last_write,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn receipt(r: server_api::PluginDataReceipt) -> data::Receipt {
|
||||||
|
data::Receipt {
|
||||||
|
mutation_id: r.mutation_id,
|
||||||
|
result_digest: r.result_digest,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl State {
|
||||||
|
fn ticket_world(&self) -> Result<(), errors::HostError> {
|
||||||
|
if self.manifest.runtime.world != plugin::PLUGIN_COMPONENT_TICKET_TOOL_WORLD {
|
||||||
|
return Err(denied());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn data_schema(&self, c: &Collection) -> Result<&serde_json::Value, errors::HostError> {
|
||||||
|
self.manifest
|
||||||
|
.data_collections
|
||||||
|
.iter()
|
||||||
|
.find(|d| {
|
||||||
|
d.collection == c.collection
|
||||||
|
&& d.schema_id == c.schema_id
|
||||||
|
&& d.schema_version == c.schema_version
|
||||||
|
})
|
||||||
|
.map(|d| &d.schema)
|
||||||
|
.ok_or_else(denied)
|
||||||
|
}
|
||||||
|
fn data_grant(&self, ctx: &InvocationContext, c: &Collection, write: bool) -> bool {
|
||||||
|
let id = (
|
||||||
|
c.collection.clone(),
|
||||||
|
c.schema_id.clone(),
|
||||||
|
c.schema_version.clone(),
|
||||||
|
);
|
||||||
|
let requested = if write {
|
||||||
|
plugin::PluginRequestedGrant::TicketDataWrite {
|
||||||
|
collection: c.collection.clone(),
|
||||||
|
schema_id: c.schema_id.clone(),
|
||||||
|
schema_version: c.schema_version.clone(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
plugin::PluginRequestedGrant::TicketDataRead {
|
||||||
|
collection: c.collection.clone(),
|
||||||
|
schema_id: c.schema_id.clone(),
|
||||||
|
schema_version: c.schema_version.clone(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
self.manifest.requested_grants.contains(&requested)
|
||||||
|
&& (if write {
|
||||||
|
&ctx.grants.ticket_data_write
|
||||||
|
} else {
|
||||||
|
&ctx.grants.ticket_data_read
|
||||||
|
})
|
||||||
|
.contains(&id)
|
||||||
|
}
|
||||||
|
fn check_cell(&self, namespace: &Collection, c: &Cell) -> Result<(), errors::HostError> {
|
||||||
|
if let Cell::Present { value_json, .. } = c {
|
||||||
|
if value_json.len() > server_api::PLUGIN_DATA_VALUE_LIMIT {
|
||||||
|
return Err(protocol());
|
||||||
|
}
|
||||||
|
let value = serde_json::from_str(value_json).map_err(|_| protocol())?;
|
||||||
|
plugin::schema::validate_data_value(self.data_schema(namespace)?, &value)
|
||||||
|
.map_err(|_| protocol())?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn data_call(
|
||||||
|
&mut self,
|
||||||
|
target: Resource<Ticket>,
|
||||||
|
operation: Op,
|
||||||
|
) -> wasmtime::Result<Result<Reply, errors::HostError>> {
|
||||||
|
let context = self.current()?;
|
||||||
|
let checked = (|| {
|
||||||
|
self.operation()?;
|
||||||
|
self.ticket_world()?;
|
||||||
|
crate::owner::validate_operation(&operation).map_err(|_| invalid())?;
|
||||||
|
let (namespace, write, lookup) = match &operation {
|
||||||
|
Op::Get(a) => (&a.collection, false, false),
|
||||||
|
Op::Mutate { address, .. } => (&address.collection, true, false),
|
||||||
|
Op::List { collection, .. } => (collection, false, false),
|
||||||
|
Op::LookupMutation { address, .. } => (&address.collection, false, true),
|
||||||
|
};
|
||||||
|
let schema = self.data_schema(namespace)?;
|
||||||
|
if !self.data_grant(&context, namespace, write || lookup) {
|
||||||
|
return Err(denied());
|
||||||
|
}
|
||||||
|
if let Op::Mutate {
|
||||||
|
mutation: Mutation::Put { value_json, .. },
|
||||||
|
..
|
||||||
|
} = &operation
|
||||||
|
{
|
||||||
|
let value = serde_json::from_str(value_json).map_err(|_| invalid())?;
|
||||||
|
plugin::schema::validate_data_value(schema, &value).map_err(|_| invalid())?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})();
|
||||||
|
if let Err(e) = checked {
|
||||||
|
return Ok(Err(e));
|
||||||
|
}
|
||||||
|
let binding = self.table.get(&target)?.binding.clone();
|
||||||
|
let namespace = match &operation {
|
||||||
|
Op::Get(a) | Op::Mutate { address: a, .. } | Op::LookupMutation { address: a, .. } => {
|
||||||
|
a.collection.clone()
|
||||||
|
}
|
||||||
|
Op::List { collection, .. } => collection.clone(),
|
||||||
|
};
|
||||||
|
let list_limit = if let Op::List { limit, .. } = &operation {
|
||||||
|
Some(*limit as usize)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let mutating = matches!(&operation, Op::Mutate { .. });
|
||||||
|
let expected_reply = match &operation {
|
||||||
|
Op::Get(_) => 0,
|
||||||
|
Op::Mutate { .. } => 1,
|
||||||
|
Op::List { .. } => 2,
|
||||||
|
Op::LookupMutation { .. } => 3,
|
||||||
|
};
|
||||||
|
let reply = match self
|
||||||
|
.provider
|
||||||
|
.ticket_data(&context, binding.as_ref(), operation)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(reply) => reply,
|
||||||
|
Err(e) => return Ok(Err(e)),
|
||||||
|
};
|
||||||
|
check_current(&context).map_err(|_| wasmtime::Error::msg("late Ticket data result"))?;
|
||||||
|
let actual_reply = match &reply {
|
||||||
|
Reply::Cell(_) => 0,
|
||||||
|
Reply::Mutated(_) => 1,
|
||||||
|
Reply::Listed(_) => 2,
|
||||||
|
Reply::MutationLookup(_) => 3,
|
||||||
|
};
|
||||||
|
if actual_reply != expected_reply {
|
||||||
|
let mut error = protocol();
|
||||||
|
if mutating {
|
||||||
|
error.code = errors::Code::Indeterminate;
|
||||||
|
error.effect = errors::EffectObservation::Indeterminate;
|
||||||
|
}
|
||||||
|
return Ok(Err(error));
|
||||||
|
}
|
||||||
|
let valid = match &reply {
|
||||||
|
Reply::Cell(c) => self.check_cell(&namespace, c),
|
||||||
|
Reply::Listed(page) => {
|
||||||
|
if page.entries.len() > list_limit.unwrap_or(0)
|
||||||
|
|| page.entries.windows(2).any(|p| p[0].key >= p[1].key)
|
||||||
|
|| page.entries.iter().any(|e| {
|
||||||
|
e.key.is_empty()
|
||||||
|
|| e.key.len() > 256
|
||||||
|
|| matches!(e.cell, Cell::NeverWritten)
|
||||||
|
})
|
||||||
|
|| page
|
||||||
|
.next_cursor
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|s| s.is_empty() || s.len() > 4096)
|
||||||
|
{
|
||||||
|
Err(protocol())
|
||||||
|
} else {
|
||||||
|
page.entries
|
||||||
|
.iter()
|
||||||
|
.try_for_each(|e| self.check_cell(&namespace, &e.cell))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => Ok(()),
|
||||||
|
};
|
||||||
|
if let Err(e) = valid {
|
||||||
|
return Ok(Err(e));
|
||||||
|
}
|
||||||
|
Ok(Ok(reply))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl objects::HostTicket for State {
|
||||||
|
async fn drop(&mut self, target: Resource<Ticket>) -> wasmtime::Result<()> {
|
||||||
|
self.table.delete(target)?;
|
||||||
|
self.resources -= 1;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl objects::Host for State {
|
||||||
|
async fn resolve_ticket(
|
||||||
|
&mut self,
|
||||||
|
ticket_ref: String,
|
||||||
|
requested: objects::Access,
|
||||||
|
) -> wasmtime::Result<Result<Resource<Ticket>, errors::HostError>> {
|
||||||
|
let context = self.current()?;
|
||||||
|
if let Err(e) = self.operation().and_then(|_| self.ticket_world()) {
|
||||||
|
return Ok(Err(e));
|
||||||
|
}
|
||||||
|
if ticket_ref.is_empty() || ticket_ref.len() > 256 {
|
||||||
|
return Ok(Err(invalid()));
|
||||||
|
}
|
||||||
|
let writable = matches!(requested, objects::Access::ExtensionWrite);
|
||||||
|
let allowed = self.manifest.requested_grants.iter().any(|g| match g {
|
||||||
|
plugin::PluginRequestedGrant::TicketRead => !writable && context.grants.ticket_read,
|
||||||
|
plugin::PluginRequestedGrant::TicketDataRead {
|
||||||
|
collection,
|
||||||
|
schema_id,
|
||||||
|
schema_version,
|
||||||
|
} => {
|
||||||
|
!writable
|
||||||
|
&& context.grants.ticket_data_read.contains(&(
|
||||||
|
collection.clone(),
|
||||||
|
schema_id.clone(),
|
||||||
|
schema_version.clone(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
plugin::PluginRequestedGrant::TicketDataWrite {
|
||||||
|
collection,
|
||||||
|
schema_id,
|
||||||
|
schema_version,
|
||||||
|
} => {
|
||||||
|
writable
|
||||||
|
&& context.grants.ticket_data_write.contains(&(
|
||||||
|
collection.clone(),
|
||||||
|
schema_id.clone(),
|
||||||
|
schema_version.clone(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
});
|
||||||
|
if !allowed {
|
||||||
|
return Ok(Err(denied()));
|
||||||
|
}
|
||||||
|
if self.resources >= self.limits.resources {
|
||||||
|
return Ok(Err(host::error(
|
||||||
|
errors::Code::QuotaExceeded,
|
||||||
|
"resource limit",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let binding = match self
|
||||||
|
.provider
|
||||||
|
.resolve_ticket(&context, &ticket_ref, requested)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(binding) => binding,
|
||||||
|
Err(e) => return Ok(Err(e)),
|
||||||
|
};
|
||||||
|
check_current(&context).map_err(|_| wasmtime::Error::msg("late Ticket resource"))?;
|
||||||
|
let target = self.table.push(Ticket { binding })?;
|
||||||
|
self.resources += 1;
|
||||||
|
Ok(Ok(target))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl ticket_read::Host for State {
|
||||||
|
async fn read(
|
||||||
|
&mut self,
|
||||||
|
target: Resource<Ticket>,
|
||||||
|
) -> wasmtime::Result<Result<ticket_read::Snapshot, errors::HostError>> {
|
||||||
|
let context = self.current()?;
|
||||||
|
if let Err(e) = self.operation().and_then(|_| self.ticket_world()) {
|
||||||
|
return Ok(Err(e));
|
||||||
|
}
|
||||||
|
if !context.grants.ticket_read
|
||||||
|
|| !self
|
||||||
|
.manifest
|
||||||
|
.requested_grants
|
||||||
|
.contains(&plugin::PluginRequestedGrant::TicketRead)
|
||||||
|
{
|
||||||
|
return Ok(Err(denied()));
|
||||||
|
}
|
||||||
|
let binding = self.table.get(&target)?.binding.clone();
|
||||||
|
let result = self.provider.read_ticket(&context, binding.as_ref()).await;
|
||||||
|
check_current(&context).map_err(|_| wasmtime::Error::msg("late Ticket snapshot"))?;
|
||||||
|
Ok(result.and_then(|s| {
|
||||||
|
if s.body.len() <= server_api::PLUGIN_TICKET_BODY_LIMIT {
|
||||||
|
Ok(s)
|
||||||
|
} else {
|
||||||
|
Err(protocol())
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl data::Host for State {
|
||||||
|
async fn get(
|
||||||
|
&mut self,
|
||||||
|
target: Resource<Ticket>,
|
||||||
|
cell_address: data::Address,
|
||||||
|
) -> wasmtime::Result<Result<data::Cell, errors::HostError>> {
|
||||||
|
Ok(
|
||||||
|
match self
|
||||||
|
.data_call(target, Op::Get(address(cell_address)))
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
Ok(Reply::Cell(c)) => Ok(cell(c)),
|
||||||
|
Err(e) => Err(e),
|
||||||
|
_ => Err(protocol()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
async fn put(
|
||||||
|
&mut self,
|
||||||
|
target: Resource<Ticket>,
|
||||||
|
cell_address: data::Address,
|
||||||
|
expected: data::Expectation,
|
||||||
|
operation_key: String,
|
||||||
|
value_json: String,
|
||||||
|
) -> wasmtime::Result<Result<data::Receipt, errors::HostError>> {
|
||||||
|
let expected = match expected {
|
||||||
|
data::Expectation::NeverWritten => Expectation::NeverWritten,
|
||||||
|
data::Expectation::LastWrite(id) => Expectation::LastWrite(id),
|
||||||
|
};
|
||||||
|
Ok(
|
||||||
|
match self
|
||||||
|
.data_call(
|
||||||
|
target,
|
||||||
|
Op::Mutate {
|
||||||
|
address: address(cell_address),
|
||||||
|
operation_key,
|
||||||
|
mutation: Mutation::Put {
|
||||||
|
expected,
|
||||||
|
value_json,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
Ok(Reply::Mutated(r)) => Ok(receipt(r)),
|
||||||
|
Err(e) => Err(e),
|
||||||
|
_ => Err(protocol()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
async fn delete(
|
||||||
|
&mut self,
|
||||||
|
target: Resource<Ticket>,
|
||||||
|
cell_address: data::Address,
|
||||||
|
expected_last_write: String,
|
||||||
|
operation_key: String,
|
||||||
|
) -> wasmtime::Result<Result<data::Receipt, errors::HostError>> {
|
||||||
|
Ok(
|
||||||
|
match self
|
||||||
|
.data_call(
|
||||||
|
target,
|
||||||
|
Op::Mutate {
|
||||||
|
address: address(cell_address),
|
||||||
|
operation_key,
|
||||||
|
mutation: Mutation::Delete {
|
||||||
|
expected_last_write,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
Ok(Reply::Mutated(r)) => Ok(receipt(r)),
|
||||||
|
Err(e) => Err(e),
|
||||||
|
_ => Err(protocol()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
async fn list(
|
||||||
|
&mut self,
|
||||||
|
target: Resource<Ticket>,
|
||||||
|
namespace: data::Collection,
|
||||||
|
cursor: Option<String>,
|
||||||
|
limit: u32,
|
||||||
|
) -> wasmtime::Result<Result<data::Page, errors::HostError>> {
|
||||||
|
Ok(
|
||||||
|
match self
|
||||||
|
.data_call(
|
||||||
|
target,
|
||||||
|
Op::List {
|
||||||
|
collection: collection(namespace),
|
||||||
|
cursor,
|
||||||
|
limit,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
Ok(Reply::Listed(p)) => Ok(data::Page {
|
||||||
|
entries: p
|
||||||
|
.entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|e| data::Entry {
|
||||||
|
key: e.key,
|
||||||
|
value: cell(e.cell),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
next_cursor: p.next_cursor,
|
||||||
|
}),
|
||||||
|
Err(e) => Err(e),
|
||||||
|
_ => Err(protocol()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
async fn lookup_mutation(
|
||||||
|
&mut self,
|
||||||
|
target: Resource<Ticket>,
|
||||||
|
cell_address: data::Address,
|
||||||
|
operation_key: String,
|
||||||
|
) -> wasmtime::Result<Result<data::MutationLookup, errors::HostError>> {
|
||||||
|
use server_api::PluginDataMutationLookup as Lookup;
|
||||||
|
Ok(
|
||||||
|
match self
|
||||||
|
.data_call(
|
||||||
|
target,
|
||||||
|
Op::LookupMutation {
|
||||||
|
address: address(cell_address),
|
||||||
|
operation_key,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
Ok(Reply::MutationLookup(outcome)) => Ok(match outcome {
|
||||||
|
Lookup::NotRecorded => data::MutationLookup::NotRecorded,
|
||||||
|
Lookup::Expired => data::MutationLookup::Expired,
|
||||||
|
Lookup::Committed(r) => data::MutationLookup::Committed(receipt(r)),
|
||||||
|
}),
|
||||||
|
Err(e) => Err(e),
|
||||||
|
_ => Err(protocol()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
//! cancellation-safe, bound their own allocations and never auto-retry effects.
|
//! cancellation-safe, bound their own allocations and never auto-retry effects.
|
||||||
|
|
||||||
pub use crate::bindings::yoi::host::{errors, http, logging};
|
pub use crate::bindings::yoi::host::{errors, http, logging};
|
||||||
|
pub use crate::ticket_bindings::yoi::host2_1_0::{extension_data, objects, ticket_read};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use std::{any::Any, collections::BTreeSet, sync::Arc};
|
use std::{any::Any, collections::BTreeSet, sync::Arc};
|
||||||
use tokio::time::Instant;
|
use tokio::time::Instant;
|
||||||
@@ -30,6 +31,9 @@ pub struct InvocationContext {
|
|||||||
pub struct Grants {
|
pub struct Grants {
|
||||||
pub logging: bool,
|
pub logging: bool,
|
||||||
pub connection_slots: BTreeSet<String>,
|
pub connection_slots: BTreeSet<String>,
|
||||||
|
pub ticket_read: bool,
|
||||||
|
pub ticket_data_read: BTreeSet<(String, String, String)>,
|
||||||
|
pub ticket_data_write: BTreeSet<(String, String, String)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opaque Host-owned capability, stored only within one invocation's Store.
|
/// Opaque Host-owned capability, stored only within one invocation's Store.
|
||||||
@@ -39,6 +43,11 @@ pub struct Connection {
|
|||||||
pub(crate) binding: Arc<dyn Any + Send + Sync>,
|
pub(crate) binding: Arc<dyn Any + Send + Sync>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Opaque Ticket capability stored only in one invocation's Store.
|
||||||
|
pub struct Ticket {
|
||||||
|
pub(crate) binding: Arc<dyn Any + Send + Sync>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Each operation requires a fresh owner check; admission covers pure Tools too.
|
/// Each operation requires a fresh owner check; admission covers pure Tools too.
|
||||||
pub enum Operation<'a> {
|
pub enum Operation<'a> {
|
||||||
Admit,
|
Admit,
|
||||||
@@ -54,7 +63,8 @@ pub enum Operation<'a> {
|
|||||||
Log,
|
Log,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// No default implementation is supplied. Test providers belong to test targets.
|
/// Core operations require implementations. Additive APIs default to Unsupported
|
||||||
|
/// for old providers, never to permission or success. Test providers belong to tests.
|
||||||
///
|
///
|
||||||
/// `authorize` must check the current installation/artifact/plan/subject and
|
/// `authorize` must check the current installation/artifact/plan/subject and
|
||||||
/// operation against live owner policy. `open`/`send` must also enforce concrete
|
/// operation against live owner policy. `open`/`send` must also enforce concrete
|
||||||
@@ -63,6 +73,35 @@ pub enum Operation<'a> {
|
|||||||
/// dropped on cancellation/deadline; providers must not leave detached work.
|
/// dropped on cancellation/deadline; providers must not leave detached work.
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait Host: Send + Sync {
|
pub trait Host: Send + Sync {
|
||||||
|
/// Old providers fail closed, never infer permission from an old grant.
|
||||||
|
async fn resolve_ticket(
|
||||||
|
&self,
|
||||||
|
_context: &InvocationContext,
|
||||||
|
_ticket_ref: &str,
|
||||||
|
_access: objects::Access,
|
||||||
|
) -> Result<Arc<dyn Any + Send + Sync>, errors::HostError> {
|
||||||
|
Err(error(errors::Code::Unsupported, "Ticket API unavailable"))
|
||||||
|
}
|
||||||
|
async fn read_ticket(
|
||||||
|
&self,
|
||||||
|
_context: &InvocationContext,
|
||||||
|
_binding: &(dyn Any + Send + Sync),
|
||||||
|
) -> Result<ticket_read::Snapshot, errors::HostError> {
|
||||||
|
Err(error(errors::Code::Unsupported, "Ticket API unavailable"))
|
||||||
|
}
|
||||||
|
/// Providers live-authorize the concrete operation, Ticket and namespace.
|
||||||
|
async fn ticket_data(
|
||||||
|
&self,
|
||||||
|
_context: &InvocationContext,
|
||||||
|
_binding: &(dyn Any + Send + Sync),
|
||||||
|
_operation: TicketDataOperation,
|
||||||
|
) -> Result<TicketDataResult, errors::HostError> {
|
||||||
|
Err(error(
|
||||||
|
errors::Code::Unsupported,
|
||||||
|
"Ticket data API unavailable",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
async fn authorize(
|
async fn authorize(
|
||||||
&self,
|
&self,
|
||||||
context: &InvocationContext,
|
context: &InvocationContext,
|
||||||
@@ -94,3 +133,30 @@ pub(crate) fn error(code: errors::Code, message: &str) -> errors::HostError {
|
|||||||
effect: errors::EffectObservation::NotDispatched,
|
effect: errors::EffectObservation::NotDispatched,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Portable provider boundary shared by Remote and Embedded owners.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum TicketDataOperation {
|
||||||
|
Get(server_api::PluginDataAddress),
|
||||||
|
Mutate {
|
||||||
|
address: server_api::PluginDataAddress,
|
||||||
|
operation_key: String,
|
||||||
|
mutation: server_api::PluginDataMutation,
|
||||||
|
},
|
||||||
|
List {
|
||||||
|
collection: server_api::PluginDataCollection,
|
||||||
|
cursor: Option<String>,
|
||||||
|
limit: u32,
|
||||||
|
},
|
||||||
|
LookupMutation {
|
||||||
|
address: server_api::PluginDataAddress,
|
||||||
|
operation_key: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum TicketDataResult {
|
||||||
|
Cell(server_api::PluginDataCell),
|
||||||
|
Mutated(server_api::PluginDataReceipt),
|
||||||
|
Listed(server_api::PluginDataPage),
|
||||||
|
MutationLookup(server_api::PluginDataMutationLookup),
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,3 +16,17 @@ pub mod host;
|
|||||||
pub mod owner;
|
pub mod owner;
|
||||||
mod sandbox;
|
mod sandbox;
|
||||||
pub use executor::*;
|
pub use executor::*;
|
||||||
|
|
||||||
|
/// Additive Ticket imports share the original v2 error types.
|
||||||
|
pub mod ticket_bindings {
|
||||||
|
wasmtime::component::bindgen!({
|
||||||
|
path: "../../resources/plugin/wit-ticket",
|
||||||
|
world: "ticket-tool-plugin",
|
||||||
|
imports: { default: async | trappable },
|
||||||
|
exports: { default: async },
|
||||||
|
with: {
|
||||||
|
"yoi:host/errors@2.0.0": crate::bindings::yoi::host::errors,
|
||||||
|
"yoi:host/objects@2.1.0/ticket": crate::host::Ticket,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
//! Shared owner transport and WIT Host adapter. In-process owners implement the
|
//! Shared owner transport and WIT Host adapter. In-process owners implement the
|
||||||
//! same trait directly; a failed remote call never selects a local fallback.
|
//! same trait directly; a failed remote call never selects a local fallback.
|
||||||
use crate::host::{Host, InvocationContext, Operation, errors, http, logging};
|
use crate::host::{
|
||||||
|
Host, InvocationContext, Operation, TicketDataOperation, TicketDataResult, errors, http,
|
||||||
|
logging, objects, ticket_read,
|
||||||
|
};
|
||||||
|
mod ticket;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use server_api::{
|
use server_api::{
|
||||||
PluginConsumer, PluginEffectObservation, PluginHostError, PluginHostErrorCode,
|
PluginConsumer, PluginEffectObservation, PluginHostError, PluginHostErrorCode,
|
||||||
@@ -12,6 +16,7 @@ use std::{
|
|||||||
sync::Arc,
|
sync::Arc,
|
||||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||||
};
|
};
|
||||||
|
pub(crate) use ticket::validate_operation;
|
||||||
use tokio::time::Instant;
|
use tokio::time::Instant;
|
||||||
|
|
||||||
/// Bound to one authenticated caller and Workspace by the integration. The owner
|
/// Bound to one authenticated caller and Workspace by the integration. The owner
|
||||||
@@ -44,11 +49,12 @@ pub trait PluginHostOwner: Send + Sync {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Finite generated-client response budget for the supported one-MiB binary
|
/// Finite generated-client response budget: a 100-cell page of 64-KiB JSON
|
||||||
/// body, its worst-case JSON numeric-array expansion, headers and envelope.
|
/// strings, their wire escaping, plus keys/receipt metadata; also covers the
|
||||||
|
/// original one-MiB binary HTTP body and numeric-array expansion.
|
||||||
/// Set `response_body_limit` on the injected client before constructing a Remote
|
/// Set `response_body_limit` on the injected client before constructing a Remote
|
||||||
/// owner; a smaller operator-selected limit may reject otherwise valid replies.
|
/// owner; a smaller operator-selected limit may reject otherwise valid replies.
|
||||||
pub const PLUGIN_HOST_RESPONSE_BODY_LIMIT: usize = 4 * 1024 * 1024 + 512 * 1024;
|
pub const PLUGIN_HOST_RESPONSE_BODY_LIMIT: usize = 16 * 1024 * 1024;
|
||||||
|
|
||||||
/// Uses an injected, already configured generated client (including its existing
|
/// Uses an injected, already configured generated client (including its existing
|
||||||
/// Runtime request authorizer). This adapter never creates or owns credentials.
|
/// Runtime request authorizer). This adapter never creates or owns credentials.
|
||||||
@@ -72,7 +78,10 @@ impl<A: server_api::client_support::RequestAuthorizer> PluginHostOwner
|
|||||||
&self,
|
&self,
|
||||||
request: PluginHostRequest,
|
request: PluginHostRequest,
|
||||||
) -> Result<PluginHostResponse, PluginHostError> {
|
) -> Result<PluginHostResponse, PluginHostError> {
|
||||||
let may_dispatch = matches!(request, PluginHostRequest::Send { .. });
|
let may_dispatch = matches!(
|
||||||
|
request,
|
||||||
|
PluginHostRequest::Send { .. } | PluginHostRequest::DataMutate { .. }
|
||||||
|
);
|
||||||
self.client
|
self.client
|
||||||
.plugin_host(self.workspace_id.clone(), request)
|
.plugin_host(self.workspace_id.clone(), request)
|
||||||
.await
|
.await
|
||||||
@@ -210,6 +219,9 @@ impl OwnerHost {
|
|||||||
|| context.invocation_id != bound.invocation_id
|
|| context.invocation_id != bound.invocation_id
|
||||||
|| context.artifact != bound.artifact
|
|| context.artifact != bound.artifact
|
||||||
|| context.tool_name != bound.tool_name
|
|| context.tool_name != bound.tool_name
|
||||||
|
|| context.grants.ticket_read != bound.grants.ticket_read
|
||||||
|
|| context.grants.ticket_data_read != bound.grants.ticket_data_read
|
||||||
|
|| context.grants.ticket_data_write != bound.grants.ticket_data_write
|
||||||
|| context.grants.logging != bound.grants.logging
|
|| context.grants.logging != bound.grants.logging
|
||||||
|| context.grants.connection_slots != bound.grants.connection_slots
|
|| context.grants.connection_slots != bound.grants.connection_slots
|
||||||
// The executor may shorten the deadline and derive a child token.
|
// The executor may shorten the deadline and derive a child token.
|
||||||
@@ -253,6 +265,36 @@ impl OwnerHost {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Host for OwnerHost {
|
impl Host for OwnerHost {
|
||||||
|
async fn resolve_ticket(
|
||||||
|
&self,
|
||||||
|
context: &InvocationContext,
|
||||||
|
ticket_ref: &str,
|
||||||
|
access: objects::Access,
|
||||||
|
) -> Result<Arc<dyn Any + Send + Sync>, errors::HostError> {
|
||||||
|
self.resolve_ticket_binding(context, ticket_ref, access)
|
||||||
|
.await
|
||||||
|
.map_err(wit_error)
|
||||||
|
}
|
||||||
|
async fn read_ticket(
|
||||||
|
&self,
|
||||||
|
context: &InvocationContext,
|
||||||
|
binding: &(dyn Any + Send + Sync),
|
||||||
|
) -> Result<ticket_read::Snapshot, errors::HostError> {
|
||||||
|
self.read_ticket_binding(context, binding)
|
||||||
|
.await
|
||||||
|
.map_err(wit_error)
|
||||||
|
}
|
||||||
|
async fn ticket_data(
|
||||||
|
&self,
|
||||||
|
context: &InvocationContext,
|
||||||
|
binding: &(dyn Any + Send + Sync),
|
||||||
|
operation: TicketDataOperation,
|
||||||
|
) -> Result<TicketDataResult, errors::HostError> {
|
||||||
|
self.ticket_data_binding(context, binding, operation)
|
||||||
|
.await
|
||||||
|
.map_err(wit_error)
|
||||||
|
}
|
||||||
|
|
||||||
async fn authorize(
|
async fn authorize(
|
||||||
&self,
|
&self,
|
||||||
context: &InvocationContext,
|
context: &InvocationContext,
|
||||||
@@ -447,7 +489,10 @@ async fn call_owner(
|
|||||||
request: PluginHostRequest,
|
request: PluginHostRequest,
|
||||||
) -> Result<PluginHostResponse, PluginHostError> {
|
) -> Result<PluginHostResponse, PluginHostError> {
|
||||||
check_lifetime(context)?;
|
check_lifetime(context)?;
|
||||||
let effect = if matches!(request, PluginHostRequest::Send { .. }) {
|
let effect = if matches!(
|
||||||
|
request,
|
||||||
|
PluginHostRequest::Send { .. } | PluginHostRequest::DataMutate { .. }
|
||||||
|
) {
|
||||||
PluginEffectObservation::Indeterminate
|
PluginEffectObservation::Indeterminate
|
||||||
} else {
|
} else {
|
||||||
PluginEffectObservation::NotDispatched
|
PluginEffectObservation::NotDispatched
|
||||||
@@ -588,6 +633,40 @@ mod tests {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
PluginHostRequest::Log { .. } => PluginHostResponse::Logged,
|
PluginHostRequest::Log { .. } => PluginHostResponse::Logged,
|
||||||
|
PluginHostRequest::ResolveTicket { .. } => PluginHostResponse::TicketResolved {
|
||||||
|
ticket_id: "canonical-ticket".into(),
|
||||||
|
},
|
||||||
|
PluginHostRequest::ReadTicket { .. } => PluginHostResponse::TicketRead {
|
||||||
|
snapshot: server_api::PluginTicketSnapshot {
|
||||||
|
title: "title".into(),
|
||||||
|
case_state: server_api::PluginTicketCaseState::Open,
|
||||||
|
content_digest: "digest".into(),
|
||||||
|
body: "bounded".into(),
|
||||||
|
truncated: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PluginHostRequest::DataGet { .. } => PluginHostResponse::DataCell {
|
||||||
|
cell: server_api::PluginDataCell::NeverWritten,
|
||||||
|
},
|
||||||
|
PluginHostRequest::DataMutate { operation_key, .. } => {
|
||||||
|
PluginHostResponse::DataMutated {
|
||||||
|
receipt: server_api::PluginDataReceipt {
|
||||||
|
mutation_id: operation_key,
|
||||||
|
result_digest: "digest".into(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PluginHostRequest::DataList { .. } => PluginHostResponse::DataListed {
|
||||||
|
page: server_api::PluginDataPage {
|
||||||
|
entries: vec![],
|
||||||
|
next_cursor: None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PluginHostRequest::DataLookupMutation { .. } => {
|
||||||
|
PluginHostResponse::DataMutationLookup {
|
||||||
|
outcome: server_api::PluginDataMutationLookup::NotRecorded,
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -607,6 +686,7 @@ mod tests {
|
|||||||
grants: Grants {
|
grants: Grants {
|
||||||
logging: true,
|
logging: true,
|
||||||
connection_slots: ["api".into(), "other".into()].into(),
|
connection_slots: ["api".into(), "other".into()].into(),
|
||||||
|
..Default::default()
|
||||||
},
|
},
|
||||||
deadline: Instant::now() + Duration::from_secs(60),
|
deadline: Instant::now() + Duration::from_secs(60),
|
||||||
cancellation: CancellationToken::new(),
|
cancellation: CancellationToken::new(),
|
||||||
@@ -860,6 +940,192 @@ mod tests {
|
|||||||
assert_eq!(owner.requests.lock().unwrap().len(), count + 1);
|
assert_eq!(owner.requests.lock().unwrap().len(), count + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn ticket_context() -> InvocationContext {
|
||||||
|
let mut c = context();
|
||||||
|
c.grants.ticket_read = true;
|
||||||
|
c.grants
|
||||||
|
.ticket_data_read
|
||||||
|
.insert(("notes".into(), "note".into(), "1".into()));
|
||||||
|
c.grants.ticket_data_write = c.grants.ticket_data_read.clone();
|
||||||
|
c
|
||||||
|
}
|
||||||
|
fn cell_address() -> server_api::PluginDataAddress {
|
||||||
|
server_api::PluginDataAddress {
|
||||||
|
collection: server_api::PluginDataCollection {
|
||||||
|
collection: "notes".into(),
|
||||||
|
schema_id: "note".into(),
|
||||||
|
schema_version: "1".into(),
|
||||||
|
},
|
||||||
|
key: "key".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn remote_and_direct_ticket_resources_reauthorize_and_reject_foreign_handles() {
|
||||||
|
let mut transcripts = Vec::new();
|
||||||
|
for use_remote in [false, true] {
|
||||||
|
let owner = Arc::new(TestOwner::default());
|
||||||
|
let (transport, _endpoint): (Arc<dyn PluginHostOwner>, _) = if use_remote {
|
||||||
|
let (t, e) = remote(owner.clone()).await;
|
||||||
|
(t, Some(e))
|
||||||
|
} else {
|
||||||
|
(owner.clone(), None)
|
||||||
|
};
|
||||||
|
let host = OwnerHost::begin(transport.clone(), ticket_context(), consumer())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let foreign = OwnerHost::begin(transport, ticket_context(), consumer())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let resource = host
|
||||||
|
.resolve_ticket(host.context(), "T-795", objects::Access::ExtensionWrite)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let other = foreign
|
||||||
|
.resolve_ticket(foreign.context(), "T-795", objects::Access::ExtensionWrite)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
// Test owner deliberately reuses invocation IDs. Private Arc identity,
|
||||||
|
// not the string or canonical Ticket ID, still rejects foreign use.
|
||||||
|
let count = owner.requests.lock().unwrap().len();
|
||||||
|
assert_eq!(
|
||||||
|
host.read_ticket(host.context(), other.as_ref())
|
||||||
|
.await
|
||||||
|
.unwrap_err()
|
||||||
|
.code,
|
||||||
|
errors::Code::Denied
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
host.ticket_data(
|
||||||
|
host.context(),
|
||||||
|
other.as_ref(),
|
||||||
|
TicketDataOperation::Get(cell_address())
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err()
|
||||||
|
.code,
|
||||||
|
errors::Code::Denied
|
||||||
|
);
|
||||||
|
let mut widened = host.context().clone();
|
||||||
|
widened
|
||||||
|
.grants
|
||||||
|
.ticket_data_read
|
||||||
|
.insert(("foreign".into(), "note".into(), "1".into()));
|
||||||
|
assert_eq!(
|
||||||
|
host.read_ticket(&widened, resource.as_ref())
|
||||||
|
.await
|
||||||
|
.unwrap_err()
|
||||||
|
.code,
|
||||||
|
errors::Code::Denied
|
||||||
|
);
|
||||||
|
assert_eq!(owner.requests.lock().unwrap().len(), count);
|
||||||
|
assert_eq!(
|
||||||
|
host.read_ticket(host.context(), resource.as_ref())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.body,
|
||||||
|
"bounded"
|
||||||
|
);
|
||||||
|
let read_only = host
|
||||||
|
.resolve_ticket(host.context(), "T-795", objects::Access::Read)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mutation = TicketDataOperation::Mutate {
|
||||||
|
address: cell_address(),
|
||||||
|
operation_key: "write".into(),
|
||||||
|
mutation: server_api::PluginDataMutation::Put {
|
||||||
|
expected: server_api::PluginDataExpectation::NeverWritten,
|
||||||
|
value_json: "{}".into(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
host.ticket_data(host.context(), read_only.as_ref(), mutation.clone())
|
||||||
|
.await
|
||||||
|
.unwrap_err()
|
||||||
|
.code,
|
||||||
|
errors::Code::Denied
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
host.ticket_data(host.context(), resource.as_ref(), mutation)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
TicketDataResult::Mutated(_)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
host.ticket_data(
|
||||||
|
host.context(),
|
||||||
|
resource.as_ref(),
|
||||||
|
TicketDataOperation::Get(cell_address())
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
TicketDataResult::Cell(_)
|
||||||
|
));
|
||||||
|
host.ticket_data(
|
||||||
|
host.context(),
|
||||||
|
resource.as_ref(),
|
||||||
|
TicketDataOperation::List {
|
||||||
|
collection: cell_address().collection,
|
||||||
|
cursor: None,
|
||||||
|
limit: 10,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
host.ticket_data(
|
||||||
|
host.context(),
|
||||||
|
resource.as_ref(),
|
||||||
|
TicketDataOperation::LookupMutation {
|
||||||
|
address: cell_address(),
|
||||||
|
operation_key: "write".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mut wrong_schema = cell_address();
|
||||||
|
wrong_schema.collection.schema_version = "2".into();
|
||||||
|
assert_eq!(
|
||||||
|
host.ticket_data(
|
||||||
|
host.context(),
|
||||||
|
resource.as_ref(),
|
||||||
|
TicketDataOperation::Get(wrong_schema)
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err()
|
||||||
|
.code,
|
||||||
|
errors::Code::Denied
|
||||||
|
);
|
||||||
|
owner.revoked.store(true, Ordering::SeqCst);
|
||||||
|
assert_eq!(
|
||||||
|
host.read_ticket(host.context(), resource.as_ref())
|
||||||
|
.await
|
||||||
|
.unwrap_err()
|
||||||
|
.code,
|
||||||
|
errors::Code::Denied
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
host.ticket_data(
|
||||||
|
host.context(),
|
||||||
|
resource.as_ref(),
|
||||||
|
TicketDataOperation::Get(cell_address())
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err()
|
||||||
|
.code,
|
||||||
|
errors::Code::Denied
|
||||||
|
);
|
||||||
|
host.context().cancellation.cancel();
|
||||||
|
assert_eq!(
|
||||||
|
host.read_ticket(host.context(), resource.as_ref())
|
||||||
|
.await
|
||||||
|
.unwrap_err()
|
||||||
|
.code,
|
||||||
|
errors::Code::Cancelled
|
||||||
|
);
|
||||||
|
transcripts.push(owner.requests.lock().unwrap().clone());
|
||||||
|
}
|
||||||
|
assert_eq!(transcripts[0], transcripts[1]);
|
||||||
|
}
|
||||||
|
|
||||||
struct PendingOwner {
|
struct PendingOwner {
|
||||||
inner: TestOwner,
|
inner: TestOwner,
|
||||||
begin: bool,
|
begin: bool,
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
//! Invocation-local resource identity is independent of owner Ticket selectors.
|
||||||
|
use super::*;
|
||||||
|
use server_api::*;
|
||||||
|
|
||||||
|
struct OwnerTicket {
|
||||||
|
invocation_id: String,
|
||||||
|
ticket_id: String,
|
||||||
|
writable: bool,
|
||||||
|
identity: Arc<()>,
|
||||||
|
}
|
||||||
|
fn denied() -> PluginHostError {
|
||||||
|
local_error(
|
||||||
|
PluginHostErrorCode::Denied,
|
||||||
|
PluginEffectObservation::NotDispatched,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fn invalid() -> PluginHostError {
|
||||||
|
local_error(
|
||||||
|
PluginHostErrorCode::InvalidInput,
|
||||||
|
PluginEffectObservation::NotDispatched,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
impl OwnerHost {
|
||||||
|
fn ticket<'a>(
|
||||||
|
&self,
|
||||||
|
binding: &'a (dyn Any + Send + Sync),
|
||||||
|
) -> Result<&'a OwnerTicket, PluginHostError> {
|
||||||
|
binding
|
||||||
|
.downcast_ref::<OwnerTicket>()
|
||||||
|
.filter(|t| {
|
||||||
|
t.invocation_id == self.context.invocation_id
|
||||||
|
&& Arc::ptr_eq(&t.identity, &self.binding_identity)
|
||||||
|
})
|
||||||
|
.ok_or_else(denied)
|
||||||
|
}
|
||||||
|
pub(super) async fn resolve_ticket_binding(
|
||||||
|
&self,
|
||||||
|
context: &InvocationContext,
|
||||||
|
ticket_ref: &str,
|
||||||
|
access: objects::Access,
|
||||||
|
) -> Result<Arc<dyn Any + Send + Sync>, PluginHostError> {
|
||||||
|
self.check_context(context)?;
|
||||||
|
if ticket_ref.is_empty() || ticket_ref.len() > 256 {
|
||||||
|
return Err(invalid());
|
||||||
|
}
|
||||||
|
let writable = matches!(access, objects::Access::ExtensionWrite);
|
||||||
|
if if writable {
|
||||||
|
context.grants.ticket_data_write.is_empty()
|
||||||
|
} else {
|
||||||
|
!context.grants.ticket_read && context.grants.ticket_data_read.is_empty()
|
||||||
|
} {
|
||||||
|
return Err(denied());
|
||||||
|
}
|
||||||
|
match call_owner(
|
||||||
|
self.owner.as_ref(),
|
||||||
|
context,
|
||||||
|
PluginHostRequest::ResolveTicket {
|
||||||
|
invocation_id: context.invocation_id.clone(),
|
||||||
|
ticket_ref: ticket_ref.into(),
|
||||||
|
access: if writable {
|
||||||
|
PluginTicketAccess::ExtensionWrite
|
||||||
|
} else {
|
||||||
|
PluginTicketAccess::Read
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
PluginHostResponse::TicketResolved { ticket_id }
|
||||||
|
if !ticket_id.is_empty() && ticket_id.len() <= 256 =>
|
||||||
|
{
|
||||||
|
Ok(Arc::new(OwnerTicket {
|
||||||
|
invocation_id: context.invocation_id.clone(),
|
||||||
|
ticket_id,
|
||||||
|
writable,
|
||||||
|
identity: self.binding_identity.clone(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
_ => Err(protocol_error(false)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(super) async fn read_ticket_binding(
|
||||||
|
&self,
|
||||||
|
context: &InvocationContext,
|
||||||
|
binding: &(dyn Any + Send + Sync),
|
||||||
|
) -> Result<ticket_read::Snapshot, PluginHostError> {
|
||||||
|
self.check_context(context)?;
|
||||||
|
let ticket = self.ticket(binding)?;
|
||||||
|
if !context.grants.ticket_read {
|
||||||
|
return Err(denied());
|
||||||
|
}
|
||||||
|
match call_owner(
|
||||||
|
self.owner.as_ref(),
|
||||||
|
context,
|
||||||
|
PluginHostRequest::ReadTicket {
|
||||||
|
invocation_id: context.invocation_id.clone(),
|
||||||
|
ticket_id: ticket.ticket_id.clone(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
PluginHostResponse::TicketRead { snapshot }
|
||||||
|
if snapshot.body.len() <= PLUGIN_TICKET_BODY_LIMIT =>
|
||||||
|
{
|
||||||
|
Ok(ticket_read::Snapshot {
|
||||||
|
title: snapshot.title,
|
||||||
|
state: match snapshot.case_state {
|
||||||
|
PluginTicketCaseState::Open => ticket_read::CaseState::Open,
|
||||||
|
PluginTicketCaseState::Closed => ticket_read::CaseState::Closed,
|
||||||
|
},
|
||||||
|
content_digest: snapshot.content_digest,
|
||||||
|
body: snapshot.body,
|
||||||
|
truncated: snapshot.truncated,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => Err(protocol_error(false)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(super) async fn ticket_data_binding(
|
||||||
|
&self,
|
||||||
|
context: &InvocationContext,
|
||||||
|
binding: &(dyn Any + Send + Sync),
|
||||||
|
operation: TicketDataOperation,
|
||||||
|
) -> Result<TicketDataResult, PluginHostError> {
|
||||||
|
self.check_context(context)?;
|
||||||
|
let ticket = self.ticket(binding)?;
|
||||||
|
let (collection, write) = match &operation {
|
||||||
|
TicketDataOperation::Get(a)
|
||||||
|
| TicketDataOperation::LookupMutation { address: a, .. } => (&a.collection, false),
|
||||||
|
TicketDataOperation::Mutate { address: a, .. } => (&a.collection, true),
|
||||||
|
TicketDataOperation::List { collection, .. } => (collection, false),
|
||||||
|
};
|
||||||
|
let identity = (
|
||||||
|
collection.collection.clone(),
|
||||||
|
collection.schema_id.clone(),
|
||||||
|
collection.schema_version.clone(),
|
||||||
|
);
|
||||||
|
let lookup = matches!(&operation, TicketDataOperation::LookupMutation { .. });
|
||||||
|
let grants = if write || lookup {
|
||||||
|
&context.grants.ticket_data_write
|
||||||
|
} else {
|
||||||
|
&context.grants.ticket_data_read
|
||||||
|
};
|
||||||
|
if !grants.contains(&identity) || ((write || lookup) && !ticket.writable) {
|
||||||
|
return Err(denied());
|
||||||
|
}
|
||||||
|
validate_operation(&operation)?;
|
||||||
|
let invocation_id = context.invocation_id.clone();
|
||||||
|
let ticket_id = ticket.ticket_id.clone();
|
||||||
|
let mutation = matches!(&operation, TicketDataOperation::Mutate { .. });
|
||||||
|
let request = match operation {
|
||||||
|
TicketDataOperation::Get(address) => PluginHostRequest::DataGet {
|
||||||
|
invocation_id,
|
||||||
|
ticket_id,
|
||||||
|
address,
|
||||||
|
},
|
||||||
|
TicketDataOperation::Mutate {
|
||||||
|
address,
|
||||||
|
operation_key,
|
||||||
|
mutation,
|
||||||
|
} => PluginHostRequest::DataMutate {
|
||||||
|
invocation_id,
|
||||||
|
ticket_id,
|
||||||
|
address,
|
||||||
|
operation_key,
|
||||||
|
mutation,
|
||||||
|
},
|
||||||
|
TicketDataOperation::List {
|
||||||
|
collection,
|
||||||
|
cursor,
|
||||||
|
limit,
|
||||||
|
} => PluginHostRequest::DataList {
|
||||||
|
invocation_id,
|
||||||
|
ticket_id,
|
||||||
|
collection,
|
||||||
|
cursor,
|
||||||
|
limit,
|
||||||
|
},
|
||||||
|
TicketDataOperation::LookupMutation {
|
||||||
|
address,
|
||||||
|
operation_key,
|
||||||
|
} => PluginHostRequest::DataLookupMutation {
|
||||||
|
invocation_id,
|
||||||
|
ticket_id,
|
||||||
|
address,
|
||||||
|
operation_key,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
match call_owner(self.owner.as_ref(), context, request.clone()).await? {
|
||||||
|
PluginHostResponse::DataCell { cell }
|
||||||
|
if matches!(request, PluginHostRequest::DataGet { .. }) =>
|
||||||
|
{
|
||||||
|
Ok(TicketDataResult::Cell(cell))
|
||||||
|
}
|
||||||
|
PluginHostResponse::DataMutated { receipt } if mutation => {
|
||||||
|
Ok(TicketDataResult::Mutated(receipt))
|
||||||
|
}
|
||||||
|
PluginHostResponse::DataListed { page }
|
||||||
|
if matches!(request, PluginHostRequest::DataList { .. }) =>
|
||||||
|
{
|
||||||
|
Ok(TicketDataResult::Listed(page))
|
||||||
|
}
|
||||||
|
PluginHostResponse::DataMutationLookup { outcome }
|
||||||
|
if matches!(request, PluginHostRequest::DataLookupMutation { .. }) =>
|
||||||
|
{
|
||||||
|
Ok(TicketDataResult::MutationLookup(outcome))
|
||||||
|
}
|
||||||
|
_ => Err(protocol_error(mutation)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn text(s: &str, limit: usize) -> bool {
|
||||||
|
!s.is_empty() && s.len() <= limit && !s.chars().any(char::is_control)
|
||||||
|
}
|
||||||
|
pub(crate) fn validate_operation(operation: &TicketDataOperation) -> Result<(), PluginHostError> {
|
||||||
|
let collection = match operation {
|
||||||
|
TicketDataOperation::Get(a)
|
||||||
|
| TicketDataOperation::Mutate { address: a, .. }
|
||||||
|
| TicketDataOperation::LookupMutation { address: a, .. } => {
|
||||||
|
if !text(&a.key, 256) {
|
||||||
|
return Err(invalid());
|
||||||
|
}
|
||||||
|
&a.collection
|
||||||
|
}
|
||||||
|
TicketDataOperation::List {
|
||||||
|
collection,
|
||||||
|
cursor,
|
||||||
|
limit,
|
||||||
|
} => {
|
||||||
|
if *limit == 0
|
||||||
|
|| *limit > PLUGIN_DATA_LIST_LIMIT
|
||||||
|
|| cursor.as_ref().is_some_and(|c| !text(c, 4096))
|
||||||
|
{
|
||||||
|
return Err(invalid());
|
||||||
|
}
|
||||||
|
collection
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if [
|
||||||
|
&collection.collection,
|
||||||
|
&collection.schema_id,
|
||||||
|
&collection.schema_version,
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.any(|s| !text(s, 128))
|
||||||
|
{
|
||||||
|
return Err(invalid());
|
||||||
|
}
|
||||||
|
match operation {
|
||||||
|
TicketDataOperation::Mutate {
|
||||||
|
operation_key,
|
||||||
|
mutation,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
if !text(operation_key, 256) {
|
||||||
|
return Err(invalid());
|
||||||
|
}
|
||||||
|
match mutation {
|
||||||
|
PluginDataMutation::Put {
|
||||||
|
expected,
|
||||||
|
value_json,
|
||||||
|
} => {
|
||||||
|
if value_json.len() > PLUGIN_DATA_VALUE_LIMIT
|
||||||
|
|| serde_json::from_str::<serde_json::Value>(value_json).is_err()
|
||||||
|
{
|
||||||
|
return Err(invalid());
|
||||||
|
}
|
||||||
|
if let PluginDataExpectation::LastWrite(id) = expected {
|
||||||
|
if !text(id, 256) {
|
||||||
|
return Err(invalid());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PluginDataMutation::Delete {
|
||||||
|
expected_last_write,
|
||||||
|
} => {
|
||||||
|
if !text(expected_last_write, 256) {
|
||||||
|
return Err(invalid());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TicketDataOperation::LookupMutation { operation_key, .. } if !text(operation_key, 256) => {
|
||||||
|
return Err(invalid());
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -111,6 +111,7 @@ fn context(component: &CompiledTool, id: &str) -> InvocationContext {
|
|||||||
grants: Grants {
|
grants: Grants {
|
||||||
logging: true,
|
logging: true,
|
||||||
connection_slots: BTreeSet::from(["api".into()]),
|
connection_slots: BTreeSet::from(["api".into()]),
|
||||||
|
..Default::default()
|
||||||
},
|
},
|
||||||
deadline: Instant::now() + Duration::from_secs(10),
|
deadline: Instant::now() + Duration::from_secs(10),
|
||||||
cancellation: CancellationToken::new(),
|
cancellation: CancellationToken::new(),
|
||||||
|
|||||||
+367
@@ -0,0 +1,367 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anyhow"
|
||||||
|
version = "1.0.104"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "2.13.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "equivalent"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ticket-test-guest"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"yoi-plugin-pdk",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foldhash"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.15.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||||
|
dependencies = [
|
||||||
|
"foldhash",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.17.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "heck"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "id-arena"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "indexmap"
|
||||||
|
version = "2.14.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||||
|
dependencies = [
|
||||||
|
"equivalent",
|
||||||
|
"hashbrown 0.17.1",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itoa"
|
||||||
|
version = "1.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "leb128fmt"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "log"
|
||||||
|
version = "0.4.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "prettyplease"
|
||||||
|
version = "0.2.37"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.107"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.47"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "semver"
|
||||||
|
version = "1.0.28"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_core"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.9",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_json"
|
||||||
|
version = "1.0.154"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
"zmij",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "2.0.119"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "3.0.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror"
|
||||||
|
version = "2.0.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror-impl",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror-impl"
|
||||||
|
version = "2.0.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.9",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-xid"
|
||||||
|
version = "0.2.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-encoder"
|
||||||
|
version = "0.244.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
|
||||||
|
dependencies = [
|
||||||
|
"leb128fmt",
|
||||||
|
"wasmparser",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-metadata"
|
||||||
|
version = "0.244.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"indexmap",
|
||||||
|
"wasm-encoder",
|
||||||
|
"wasmparser",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasmparser"
|
||||||
|
version = "0.244.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"hashbrown 0.15.5",
|
||||||
|
"indexmap",
|
||||||
|
"semver",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-bindgen"
|
||||||
|
version = "0.51.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"wit-bindgen-rust-macro",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-bindgen-core"
|
||||||
|
version = "0.51.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"heck",
|
||||||
|
"wit-parser",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-bindgen-rust"
|
||||||
|
version = "0.51.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"heck",
|
||||||
|
"indexmap",
|
||||||
|
"prettyplease",
|
||||||
|
"syn 2.0.119",
|
||||||
|
"wasm-metadata",
|
||||||
|
"wit-bindgen-core",
|
||||||
|
"wit-component",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-bindgen-rust-macro"
|
||||||
|
version = "0.51.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"prettyplease",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
"wit-bindgen-core",
|
||||||
|
"wit-bindgen-rust",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-component"
|
||||||
|
version = "0.244.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"bitflags",
|
||||||
|
"indexmap",
|
||||||
|
"log",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"serde_json",
|
||||||
|
"wasm-encoder",
|
||||||
|
"wasm-metadata",
|
||||||
|
"wasmparser",
|
||||||
|
"wit-parser",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-parser"
|
||||||
|
version = "0.244.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"id-arena",
|
||||||
|
"indexmap",
|
||||||
|
"log",
|
||||||
|
"semver",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"serde_json",
|
||||||
|
"unicode-xid",
|
||||||
|
"wasmparser",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "yoi-plugin-pdk"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror",
|
||||||
|
"wit-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zmij"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "ticket-test-guest"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[workspace]
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
yoi-plugin-pdk = { path = "../../../plugin-pdk" }
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
schema_version = 2
|
||||||
|
id = "ticket-test"
|
||||||
|
name = "Ticket test"
|
||||||
|
version = "1.0.0"
|
||||||
|
surfaces = ["tool"]
|
||||||
|
config_schema = "{}"
|
||||||
|
|
||||||
|
[runtime]
|
||||||
|
kind = "wasm-component"
|
||||||
|
component = "plugin.component.wasm"
|
||||||
|
world = "yoi:plugin/ticket-tool-plugin@2.1.0"
|
||||||
|
required_imports = ["yoi:host/errors@2.0.0", "yoi:host/objects@2.1.0", "yoi:host/ticket-read@2.1.0", "yoi:host/extension-data@2.1.0"]
|
||||||
|
|
||||||
|
[[tools]]
|
||||||
|
name = "ticket_test"
|
||||||
|
description = "Exercise the typed Ticket extension boundary"
|
||||||
|
input_schema = { type = "string" }
|
||||||
|
output_schema = { type = "null" }
|
||||||
|
error_codes = ["failed"]
|
||||||
|
|
||||||
|
[[data_collections]]
|
||||||
|
collection = "notes"
|
||||||
|
schema_id = "note"
|
||||||
|
schema_version = "1"
|
||||||
|
schema = { type = "string", maxLength = 64 }
|
||||||
|
|
||||||
|
[[requested_grants]]
|
||||||
|
kind = "ticket_read"
|
||||||
|
[[requested_grants]]
|
||||||
|
kind = "ticket_data_read"
|
||||||
|
collection = "notes"
|
||||||
|
schema_id = "note"
|
||||||
|
schema_version = "1"
|
||||||
|
[[requested_grants]]
|
||||||
|
kind = "ticket_data_write"
|
||||||
|
collection = "notes"
|
||||||
|
schema_id = "note"
|
||||||
|
schema_version = "1"
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
use yoi_plugin_pdk::{ToolContext, ToolError, ToolOutput, wit_bindgen};
|
||||||
|
wit_bindgen::generate!({
|
||||||
|
path: "../../../../resources/plugin/wit-ticket",
|
||||||
|
world: "ticket-tool-plugin",
|
||||||
|
generate_all,
|
||||||
|
runtime_path: "yoi_plugin_pdk::wit_bindgen::rt",
|
||||||
|
});
|
||||||
|
use yoi::host2_1_0::{extension_data as data, objects, ticket_read};
|
||||||
|
|
||||||
|
fn namespace() -> data::Collection {
|
||||||
|
data::Collection {
|
||||||
|
collection: "notes".into(),
|
||||||
|
schema_id: "note".into(),
|
||||||
|
schema_version: "1".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn address() -> data::Address {
|
||||||
|
data::Address {
|
||||||
|
namespace: namespace(),
|
||||||
|
key: "main".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn failure(error: yoi::host2_0_0::errors::HostError) -> ToolError {
|
||||||
|
ToolError::failed(format!("{:?}", error.code))
|
||||||
|
}
|
||||||
|
fn handle(_: ToolContext, mode: String) -> yoi_plugin_pdk::Result<ToolOutput> {
|
||||||
|
let ticket = if mode == "foreign" {
|
||||||
|
unsafe { objects::Ticket::from_handle(0) }
|
||||||
|
} else {
|
||||||
|
objects::resolve_ticket("T-795", objects::Access::ExtensionWrite).map_err(failure)?
|
||||||
|
};
|
||||||
|
let ticket = if mode == "stale" {
|
||||||
|
let handle = ticket.handle();
|
||||||
|
drop(ticket);
|
||||||
|
unsafe { objects::Ticket::from_handle(handle) }
|
||||||
|
} else {
|
||||||
|
ticket
|
||||||
|
};
|
||||||
|
let snapshot = ticket_read::read(&ticket).map_err(failure)?;
|
||||||
|
if mode == "other-schema" {
|
||||||
|
let mut a = address();
|
||||||
|
a.namespace.schema_version = "2".into();
|
||||||
|
data::get(&ticket, &a).map_err(failure)?;
|
||||||
|
}
|
||||||
|
if mode == "read" {
|
||||||
|
return ToolOutput::json(snapshot.title, ());
|
||||||
|
}
|
||||||
|
let json = if mode == "bad-schema" {
|
||||||
|
"{\"undeclared\":true}".into()
|
||||||
|
} else {
|
||||||
|
yoi_plugin_pdk::extension_data_json(&"note")?
|
||||||
|
};
|
||||||
|
let written = data::put(
|
||||||
|
&ticket,
|
||||||
|
&address(),
|
||||||
|
&data::Expectation::NeverWritten,
|
||||||
|
"put",
|
||||||
|
&json,
|
||||||
|
)
|
||||||
|
.map_err(failure)?;
|
||||||
|
assert!(matches!(
|
||||||
|
data::get(&ticket, &address()).map_err(failure)?,
|
||||||
|
data::Cell::Present(_)
|
||||||
|
));
|
||||||
|
let page = data::list(&ticket, &namespace(), None, 10).map_err(failure)?;
|
||||||
|
assert_eq!(page.entries.len(), 1);
|
||||||
|
let lookup = data::lookup_mutation(&ticket, &address(), "put").map_err(failure)?;
|
||||||
|
assert!(matches!(lookup, data::MutationLookup::Committed(_)));
|
||||||
|
let deleted =
|
||||||
|
data::delete(&ticket, &address(), &written.mutation_id, "delete").map_err(failure)?;
|
||||||
|
assert!(matches!(
|
||||||
|
data::get(&ticket, &address()).map_err(failure)?,
|
||||||
|
data::Cell::Deleted(_)
|
||||||
|
));
|
||||||
|
data::put(
|
||||||
|
&ticket,
|
||||||
|
&address(),
|
||||||
|
&data::Expectation::LastWrite(deleted.mutation_id),
|
||||||
|
"recreate",
|
||||||
|
&json,
|
||||||
|
)
|
||||||
|
.map_err(failure)?;
|
||||||
|
// Replay the original exact request even though the cell has since changed.
|
||||||
|
let replay = data::put(
|
||||||
|
&ticket,
|
||||||
|
&address(),
|
||||||
|
&data::Expectation::NeverWritten,
|
||||||
|
"put",
|
||||||
|
&json,
|
||||||
|
)
|
||||||
|
.map_err(failure)?;
|
||||||
|
assert_eq!(replay.mutation_id, written.mutation_id);
|
||||||
|
ToolOutput::json(snapshot.title, ())
|
||||||
|
}
|
||||||
|
yoi_plugin_pdk::export_ticket_component_tool!(TicketPlugin, handle);
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
//! Real Rust PDK -> Component -> Wasmtime -> OwnerHost adapter regression.
|
||||||
|
//! The deterministic owner here tests the adapter/ABI, not DB durability or
|
||||||
|
//! production authorization; real owner/storage races belong to workspace-server.
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use manifest::plugin::{self, PluginPackageLimits, PluginSourceKind};
|
||||||
|
use server_api::*;
|
||||||
|
use std::{
|
||||||
|
collections::BTreeMap,
|
||||||
|
fs,
|
||||||
|
path::Path,
|
||||||
|
process::Command,
|
||||||
|
sync::{Arc, Mutex, OnceLock},
|
||||||
|
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||||
|
};
|
||||||
|
use tokio::time::Instant;
|
||||||
|
use yoi_plugin_runtime::{
|
||||||
|
host::{CancellationToken, Grants, InvocationContext},
|
||||||
|
owner::{OwnerHost, PluginHostOwner},
|
||||||
|
*,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn component() -> &'static [u8] {
|
||||||
|
static COMPONENT: OnceLock<Vec<u8>> = OnceLock::new();
|
||||||
|
COMPONENT.get_or_init(|| {
|
||||||
|
// Fixture builds write only this explicitly isolated output directory.
|
||||||
|
let target =
|
||||||
|
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/t795-contracts/guest");
|
||||||
|
let output = Command::new(env!("CARGO"))
|
||||||
|
.args([
|
||||||
|
"build",
|
||||||
|
"--locked",
|
||||||
|
"--offline",
|
||||||
|
"--release",
|
||||||
|
"--target",
|
||||||
|
"wasm32-unknown-unknown",
|
||||||
|
"--manifest-path",
|
||||||
|
])
|
||||||
|
.arg(Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/ticket-guest/Cargo.toml"))
|
||||||
|
.arg("--target-dir")
|
||||||
|
.arg(&target)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"{}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
let core =
|
||||||
|
fs::read(target.join("wasm32-unknown-unknown/release/ticket_test_guest.wasm")).unwrap();
|
||||||
|
let bytes = wit_component::ComponentEncoder::default()
|
||||||
|
.module(&core)
|
||||||
|
.unwrap()
|
||||||
|
.validate(true)
|
||||||
|
.encode()
|
||||||
|
.unwrap();
|
||||||
|
fs::write(
|
||||||
|
target.parent().unwrap().join("ticket-tool.component.wasm"),
|
||||||
|
&bytes,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
bytes
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn artifact(manifest: &str) -> plugin::CanonicalPluginArtifact {
|
||||||
|
let target = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/t795-contracts");
|
||||||
|
fs::create_dir_all(&target).unwrap();
|
||||||
|
let dir = tempfile::tempdir_in(target).unwrap();
|
||||||
|
fs::write(dir.path().join("plugin.toml"), manifest).unwrap();
|
||||||
|
fs::write(dir.path().join("plugin.component.wasm"), component()).unwrap();
|
||||||
|
let package = plugin::read_plugin_directory(
|
||||||
|
dir.path(),
|
||||||
|
PluginSourceKind::Explicit,
|
||||||
|
&PluginPackageLimits::default(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
plugin::pack_plugin_package(&package, &PluginPackageLimits::default()).unwrap()
|
||||||
|
}
|
||||||
|
struct State {
|
||||||
|
cell: PluginDataCell,
|
||||||
|
receipts: BTreeMap<String, (PluginHostRequest, PluginDataReceipt)>,
|
||||||
|
calls: Vec<PluginHostRequest>,
|
||||||
|
}
|
||||||
|
struct Owner {
|
||||||
|
state: Mutex<State>,
|
||||||
|
revoke_after_resolve: bool,
|
||||||
|
}
|
||||||
|
impl Owner {
|
||||||
|
fn new(revoke_after_resolve: bool) -> Self {
|
||||||
|
Self {
|
||||||
|
state: Mutex::new(State {
|
||||||
|
cell: PluginDataCell::NeverWritten,
|
||||||
|
receipts: BTreeMap::new(),
|
||||||
|
calls: vec![],
|
||||||
|
}),
|
||||||
|
revoke_after_resolve,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn rejected(code: PluginHostErrorCode) -> PluginHostError {
|
||||||
|
PluginHostError {
|
||||||
|
code,
|
||||||
|
effect: PluginEffectObservation::NotDispatched,
|
||||||
|
diagnostic_id: "isolated-test".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[async_trait]
|
||||||
|
impl PluginHostOwner for Owner {
|
||||||
|
async fn call(
|
||||||
|
&self,
|
||||||
|
request: PluginHostRequest,
|
||||||
|
) -> Result<PluginHostResponse, PluginHostError> {
|
||||||
|
let mut s = self.state.lock().unwrap();
|
||||||
|
s.calls.push(request.clone());
|
||||||
|
Ok(match request.clone() {
|
||||||
|
PluginHostRequest::Begin { .. } => PluginHostResponse::Invocation {
|
||||||
|
invocation_id: "logical-invocation".into(),
|
||||||
|
expires_at_ms: SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_millis() as i64
|
||||||
|
+ 60_000,
|
||||||
|
},
|
||||||
|
PluginHostRequest::Authorize { .. } => PluginHostResponse::Authorized,
|
||||||
|
PluginHostRequest::ResolveTicket { ticket_ref, .. } => {
|
||||||
|
assert_eq!(ticket_ref, "T-795");
|
||||||
|
PluginHostResponse::TicketResolved {
|
||||||
|
ticket_id: "canonical-ticket".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PluginHostRequest::ReadTicket { ticket_id, .. } => {
|
||||||
|
assert_eq!(ticket_id, "canonical-ticket");
|
||||||
|
if self.revoke_after_resolve {
|
||||||
|
return Err(rejected(PluginHostErrorCode::Denied));
|
||||||
|
}
|
||||||
|
PluginHostResponse::TicketRead {
|
||||||
|
snapshot: PluginTicketSnapshot {
|
||||||
|
title: "unchanged title".into(),
|
||||||
|
case_state: PluginTicketCaseState::Open,
|
||||||
|
content_digest: "unchanged digest".into(),
|
||||||
|
body: "bounded body".into(),
|
||||||
|
truncated: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PluginHostRequest::DataGet { .. } => PluginHostResponse::DataCell {
|
||||||
|
cell: s.cell.clone(),
|
||||||
|
},
|
||||||
|
PluginHostRequest::DataList { .. } => PluginHostResponse::DataListed {
|
||||||
|
page: PluginDataPage {
|
||||||
|
entries: vec![PluginDataEntry {
|
||||||
|
key: "main".into(),
|
||||||
|
cell: s.cell.clone(),
|
||||||
|
}],
|
||||||
|
next_cursor: None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PluginHostRequest::DataLookupMutation { operation_key, .. } => {
|
||||||
|
PluginHostResponse::DataMutationLookup {
|
||||||
|
outcome: s
|
||||||
|
.receipts
|
||||||
|
.get(&operation_key)
|
||||||
|
.map(|(_, r)| PluginDataMutationLookup::Committed(r.clone()))
|
||||||
|
.unwrap_or(PluginDataMutationLookup::NotRecorded),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PluginHostRequest::DataMutate {
|
||||||
|
operation_key,
|
||||||
|
mutation,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
if let Some((original, receipt)) = s.receipts.get(&operation_key) {
|
||||||
|
if *original != request {
|
||||||
|
return Err(rejected(PluginHostErrorCode::Conflict));
|
||||||
|
}
|
||||||
|
return Ok(PluginHostResponse::DataMutated {
|
||||||
|
receipt: receipt.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let observed = match &s.cell {
|
||||||
|
PluginDataCell::NeverWritten => PluginDataExpectation::NeverWritten,
|
||||||
|
PluginDataCell::Present { last_write, .. } => {
|
||||||
|
PluginDataExpectation::LastWrite(last_write.clone())
|
||||||
|
}
|
||||||
|
PluginDataCell::Deleted { last_delete } => {
|
||||||
|
PluginDataExpectation::LastWrite(last_delete.clone())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let id = format!("mutation-{operation_key}");
|
||||||
|
s.cell = match mutation {
|
||||||
|
PluginDataMutation::Put {
|
||||||
|
expected,
|
||||||
|
value_json,
|
||||||
|
} => {
|
||||||
|
if expected != observed {
|
||||||
|
return Err(rejected(PluginHostErrorCode::Conflict));
|
||||||
|
}
|
||||||
|
PluginDataCell::Present {
|
||||||
|
value_json,
|
||||||
|
last_write: id.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PluginDataMutation::Delete {
|
||||||
|
expected_last_write,
|
||||||
|
} => {
|
||||||
|
if PluginDataExpectation::LastWrite(expected_last_write) != observed {
|
||||||
|
return Err(rejected(PluginHostErrorCode::Conflict));
|
||||||
|
}
|
||||||
|
PluginDataCell::Deleted {
|
||||||
|
last_delete: id.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let receipt = PluginDataReceipt {
|
||||||
|
mutation_id: id,
|
||||||
|
result_digest: "result-digest".into(),
|
||||||
|
};
|
||||||
|
s.receipts.insert(operation_key, (request, receipt.clone()));
|
||||||
|
PluginHostResponse::DataMutated { receipt }
|
||||||
|
}
|
||||||
|
_ => return Err(rejected(PluginHostErrorCode::Unsupported)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn context(compiled: &CompiledTool) -> InvocationContext {
|
||||||
|
let namespace: (String, String, String) = ("notes".into(), "note".into(), "1".into());
|
||||||
|
InvocationContext {
|
||||||
|
workspace: "ws".into(),
|
||||||
|
installation: "installation".into(),
|
||||||
|
plan: "plan".into(),
|
||||||
|
subject: "subject".into(),
|
||||||
|
invocation_id: "not-authority".into(),
|
||||||
|
artifact: compiled.identity().clone(),
|
||||||
|
tool_name: "ticket_test".into(),
|
||||||
|
grants: Grants {
|
||||||
|
ticket_read: true,
|
||||||
|
ticket_data_read: [namespace.clone()].into(),
|
||||||
|
ticket_data_write: [namespace].into(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
deadline: Instant::now() + Duration::from_secs(60),
|
||||||
|
cancellation: CancellationToken::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async fn run(
|
||||||
|
ex: &Executor,
|
||||||
|
compiled: &CompiledTool,
|
||||||
|
owner: Arc<Owner>,
|
||||||
|
mode: &str,
|
||||||
|
granted: bool,
|
||||||
|
) -> Result<InvocationResult, ExecutionError> {
|
||||||
|
let mut ctx = context(compiled);
|
||||||
|
if !granted {
|
||||||
|
ctx.grants.ticket_data_write.clear();
|
||||||
|
}
|
||||||
|
let host = Arc::new(
|
||||||
|
OwnerHost::begin(
|
||||||
|
owner,
|
||||||
|
ctx,
|
||||||
|
PluginConsumer::ToolCall {
|
||||||
|
tool_call_id: "call".into(),
|
||||||
|
tool_name: "ticket_test".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
ex.invoke(
|
||||||
|
compiled,
|
||||||
|
host.context().clone(),
|
||||||
|
host,
|
||||||
|
"ticket_test",
|
||||||
|
&format!("\"{mode}\""),
|
||||||
|
"{}",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn actual_component_resolves_reads_mutates_lists_and_reconciles_receipts() {
|
||||||
|
let artifact = artifact(include_str!("ticket-guest/plugin.toml"));
|
||||||
|
let ex = Executor::new(Limits::default()).unwrap();
|
||||||
|
let compiled = ex
|
||||||
|
.compile(
|
||||||
|
&artifact.bytes,
|
||||||
|
&artifact.package_digest,
|
||||||
|
&artifact.artifact_digest,
|
||||||
|
artifact.artifact_size,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let owner = Arc::new(Owner::new(false));
|
||||||
|
let result = run(&ex, &compiled, owner.clone(), "all", true)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result.output.summary, "unchanged title");
|
||||||
|
let s = owner.state.lock().unwrap();
|
||||||
|
assert_eq!(s.receipts.len(), 3); // put/delete/recreate, not a fourth replay
|
||||||
|
assert!(
|
||||||
|
matches!(&s.cell, PluginDataCell::Present { last_write, .. } if last_write == "mutation-recreate")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
s.calls
|
||||||
|
.iter()
|
||||||
|
.filter(|r| matches!(r, PluginHostRequest::DataMutate { .. }))
|
||||||
|
.count(),
|
||||||
|
4
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn actual_component_denies_old_grants_foreign_resources_revocation_and_bad_schema() {
|
||||||
|
let artifact = artifact(include_str!("ticket-guest/plugin.toml"));
|
||||||
|
let ex = Executor::new(Limits::default()).unwrap();
|
||||||
|
let compiled = ex
|
||||||
|
.compile(
|
||||||
|
&artifact.bytes,
|
||||||
|
&artifact.package_digest,
|
||||||
|
&artifact.artifact_digest,
|
||||||
|
artifact.artifact_size,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
for (mode, revoked, granted) in [
|
||||||
|
("read", true, true),
|
||||||
|
("foreign", false, true),
|
||||||
|
("stale", false, true),
|
||||||
|
("other-schema", false, true),
|
||||||
|
("read", false, false),
|
||||||
|
("bad-schema", false, true),
|
||||||
|
] {
|
||||||
|
let owner = Arc::new(Owner::new(revoked));
|
||||||
|
assert!(
|
||||||
|
run(&ex, &compiled, owner.clone(), mode, granted)
|
||||||
|
.await
|
||||||
|
.is_err(),
|
||||||
|
"{mode}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!owner
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.calls
|
||||||
|
.iter()
|
||||||
|
.any(|r| matches!(r, PluginHostRequest::DataMutate { .. })),
|
||||||
|
"rejection precedes write"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,9 @@ pub use chat_transport::*;
|
|||||||
mod drive;
|
mod drive;
|
||||||
mod notification;
|
mod notification;
|
||||||
mod plugin;
|
mod plugin;
|
||||||
|
pub mod plugin_data;
|
||||||
pub mod plugin_host;
|
pub mod plugin_host;
|
||||||
|
pub use plugin_data::*;
|
||||||
mod plugin_types;
|
mod plugin_types;
|
||||||
pub use notification::*;
|
pub use notification::*;
|
||||||
pub use plugin::*;
|
pub use plugin::*;
|
||||||
@@ -235,6 +237,9 @@ impl_openapi_schema!(
|
|||||||
PluginAddArtifactRequest,
|
PluginAddArtifactRequest,
|
||||||
PluginRevokeRequest,
|
PluginRevokeRequest,
|
||||||
PluginUninstallRequest,
|
PluginUninstallRequest,
|
||||||
|
PluginDataPurgeState,
|
||||||
|
PluginDataPurgeRequest,
|
||||||
|
PluginDataPurgeResponse,
|
||||||
PluginListRequest,
|
PluginListRequest,
|
||||||
PluginUploadResponse,
|
PluginUploadResponse,
|
||||||
PluginInstallation,
|
PluginInstallation,
|
||||||
@@ -3072,6 +3077,23 @@ pub trait ServerApi {
|
|||||||
#[body] request: PluginUninstallRequest,
|
#[body] request: PluginUninstallRequest,
|
||||||
) -> Result<PluginOperationResponse, RepositoryApiError>;
|
) -> Result<PluginOperationResponse, RepositoryApiError>;
|
||||||
|
|
||||||
|
#[get("/api/w/{workspace_id}/plugins/{installation_id}/data-state", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,503], bearer_auth = true, browser_auth = true)]
|
||||||
|
async fn plugin_data_observe(
|
||||||
|
&self,
|
||||||
|
#[extension] actor: RequestActor,
|
||||||
|
#[path] workspace_id: String,
|
||||||
|
#[path] installation_id: String,
|
||||||
|
) -> Result<PluginDataPurgeState, RepositoryApiError>;
|
||||||
|
|
||||||
|
#[post("/api/w/{workspace_id}/plugins/{installation_id}/data-purge", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,503], bearer_auth = true, browser_auth = true, normalize_body_errors = true)]
|
||||||
|
async fn plugin_data_purge(
|
||||||
|
&self,
|
||||||
|
#[extension] actor: RequestActor,
|
||||||
|
#[path] workspace_id: String,
|
||||||
|
#[path] installation_id: String,
|
||||||
|
#[body] request: PluginDataPurgeRequest,
|
||||||
|
) -> Result<PluginDataPurgeResponse, RepositoryApiError>;
|
||||||
|
|
||||||
#[get("/api/w/{workspace_id}/plugin-operations/{operation_id}", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,429,503], bearer_auth = true, browser_auth = true)]
|
#[get("/api/w/{workspace_id}/plugin-operations/{operation_id}", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,429,503], bearer_auth = true, browser_auth = true)]
|
||||||
async fn plugin_operation(
|
async fn plugin_operation(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -97,7 +97,31 @@ pub struct PluginConnectionMetadata {
|
|||||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||||
pub enum PluginGrantMetadata {
|
pub enum PluginGrantMetadata {
|
||||||
Logging,
|
Logging,
|
||||||
Https { slot: String },
|
Https {
|
||||||
|
slot: String,
|
||||||
|
},
|
||||||
|
TicketRead,
|
||||||
|
TicketDataRead {
|
||||||
|
collection: String,
|
||||||
|
schema_id: String,
|
||||||
|
schema_version: String,
|
||||||
|
},
|
||||||
|
TicketDataWrite {
|
||||||
|
collection: String,
|
||||||
|
schema_id: String,
|
||||||
|
schema_version: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct PluginDataSchemaMetadata {
|
||||||
|
pub collection: String,
|
||||||
|
pub schema_id: String,
|
||||||
|
pub schema_version: String,
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "unknown"))]
|
||||||
|
pub schema: serde_json::Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
@@ -117,6 +141,8 @@ pub struct PluginArtifact {
|
|||||||
pub tools: Vec<PluginToolMetadata>,
|
pub tools: Vec<PluginToolMetadata>,
|
||||||
pub connections: Vec<PluginConnectionMetadata>,
|
pub connections: Vec<PluginConnectionMetadata>,
|
||||||
pub requested_grants: Vec<PluginGrantMetadata>,
|
pub requested_grants: Vec<PluginGrantMetadata>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub data_collections: Vec<PluginDataSchemaMetadata>,
|
||||||
pub config_schema: PluginSchemaContribution,
|
pub config_schema: PluginSchemaContribution,
|
||||||
pub revoked: bool,
|
pub revoked: bool,
|
||||||
pub revocation_reason: Option<String>,
|
pub revocation_reason: Option<String>,
|
||||||
@@ -147,6 +173,25 @@ impl plugin_plan::ArtifactDescriptor for PluginArtifact {
|
|||||||
PluginGrantMetadata::Https { slot } => {
|
PluginGrantMetadata::Https { slot } => {
|
||||||
plugin_plan::PluginGrant::Https { slot: slot.clone() }
|
plugin_plan::PluginGrant::Https { slot: slot.clone() }
|
||||||
}
|
}
|
||||||
|
PluginGrantMetadata::TicketRead => plugin_plan::PluginGrant::TicketRead,
|
||||||
|
PluginGrantMetadata::TicketDataRead {
|
||||||
|
collection,
|
||||||
|
schema_id,
|
||||||
|
schema_version,
|
||||||
|
} => plugin_plan::PluginGrant::TicketDataRead {
|
||||||
|
collection: collection.clone(),
|
||||||
|
schema_id: schema_id.clone(),
|
||||||
|
schema_version: schema_version.clone(),
|
||||||
|
},
|
||||||
|
PluginGrantMetadata::TicketDataWrite {
|
||||||
|
collection,
|
||||||
|
schema_id,
|
||||||
|
schema_version,
|
||||||
|
} => plugin_plan::PluginGrant::TicketDataWrite {
|
||||||
|
collection: collection.clone(),
|
||||||
|
schema_id: schema_id.clone(),
|
||||||
|
schema_version: schema_version.clone(),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
config_schema: plugin_plan::PluginSchemaIdentity {
|
config_schema: plugin_plan::PluginSchemaIdentity {
|
||||||
@@ -160,6 +205,33 @@ impl plugin_plan::ArtifactDescriptor for PluginArtifact {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct PluginDataPurgeState {
|
||||||
|
pub installation_last_operation: String,
|
||||||
|
pub cells_digest: String,
|
||||||
|
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||||
|
pub cell_count: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct PluginDataPurgeRequest {
|
||||||
|
pub operation_key: String,
|
||||||
|
pub expected: PluginDataPurgeState,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct PluginDataPurgeResponse {
|
||||||
|
pub purge_id: String,
|
||||||
|
pub observed: PluginDataPurgeState,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
//! Ticket extension-data wire boundary. Workspace, installation and execution
|
||||||
|
//! identity come exclusively from the authenticated, owner-issued invocation.
|
||||||
|
//! Guest selectors are never authorization. Receipts share the HTTPS invocation
|
||||||
|
//! scope and expiry, and are checked after live authorization but before cell CAS.
|
||||||
|
use schemars::JsonSchema;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub const PLUGIN_TICKET_BODY_LIMIT: usize = 64 * 1024;
|
||||||
|
pub const PLUGIN_DATA_VALUE_LIMIT: usize = 64 * 1024;
|
||||||
|
pub const PLUGIN_DATA_LIST_LIMIT: u32 = 100;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum PluginTicketAccess {
|
||||||
|
Read,
|
||||||
|
ExtensionWrite,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Canonical Ticket identity is returned only after resolve authorizes it. It is
|
||||||
|
/// still a selector: every subsequent operation must reauthorize with the owner.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct PluginTicketSnapshot {
|
||||||
|
pub title: String,
|
||||||
|
pub case_state: PluginTicketCaseState,
|
||||||
|
pub content_digest: String,
|
||||||
|
pub body: String,
|
||||||
|
pub truncated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum PluginTicketCaseState {
|
||||||
|
Open,
|
||||||
|
Closed,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct PluginDataCollection {
|
||||||
|
pub collection: String,
|
||||||
|
pub schema_id: String,
|
||||||
|
pub schema_version: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct PluginDataAddress {
|
||||||
|
pub collection: PluginDataCollection,
|
||||||
|
pub key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(
|
||||||
|
tag = "kind",
|
||||||
|
content = "data",
|
||||||
|
rename_all = "snake_case",
|
||||||
|
deny_unknown_fields
|
||||||
|
)]
|
||||||
|
pub enum PluginDataExpectation {
|
||||||
|
NeverWritten,
|
||||||
|
LastWrite(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(
|
||||||
|
tag = "kind",
|
||||||
|
content = "data",
|
||||||
|
rename_all = "snake_case",
|
||||||
|
deny_unknown_fields
|
||||||
|
)]
|
||||||
|
pub enum PluginDataCell {
|
||||||
|
NeverWritten,
|
||||||
|
Present {
|
||||||
|
value_json: String,
|
||||||
|
last_write: String,
|
||||||
|
},
|
||||||
|
Deleted {
|
||||||
|
last_delete: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||||
|
pub enum PluginDataMutation {
|
||||||
|
Put {
|
||||||
|
expected: PluginDataExpectation,
|
||||||
|
value_json: String,
|
||||||
|
},
|
||||||
|
Delete {
|
||||||
|
expected_last_write: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A saved mutation result, not an observation of the current cell. The digest
|
||||||
|
/// covers the canonical resulting cell (including its final operation identity).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct PluginDataReceipt {
|
||||||
|
pub mutation_id: String,
|
||||||
|
pub result_digest: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(
|
||||||
|
tag = "kind",
|
||||||
|
content = "data",
|
||||||
|
rename_all = "snake_case",
|
||||||
|
deny_unknown_fields
|
||||||
|
)]
|
||||||
|
pub enum PluginDataMutationLookup {
|
||||||
|
NotRecorded,
|
||||||
|
Committed(PluginDataReceipt),
|
||||||
|
Expired,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct PluginDataEntry {
|
||||||
|
pub key: String,
|
||||||
|
pub cell: PluginDataCell,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct PluginDataPage {
|
||||||
|
pub entries: Vec<PluginDataEntry>,
|
||||||
|
pub next_cursor: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
#[test]
|
||||||
|
fn cell_states_and_receipt_absence_are_not_conflated() {
|
||||||
|
let never = serde_json::to_value(PluginDataCell::NeverWritten).unwrap();
|
||||||
|
let deleted = serde_json::to_value(PluginDataCell::Deleted {
|
||||||
|
last_delete: "delete-operation".into(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_ne!(never, deleted);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_value::<PluginDataCell>(never).unwrap(),
|
||||||
|
PluginDataCell::NeverWritten
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
serde_json::to_value(PluginDataMutationLookup::NotRecorded).unwrap(),
|
||||||
|
serde_json::to_value(PluginDataMutationLookup::Expired).unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn address_rejects_guest_workspace_installation_or_schema_aliases() {
|
||||||
|
let address = json!({"collection":{"collection":"notes","schema_id":"note","schema_version":"1"},"key":"main"});
|
||||||
|
assert!(serde_json::from_value::<PluginDataAddress>(address.clone()).is_ok());
|
||||||
|
for field in ["workspace_id", "installation_id", "ticket_id"] {
|
||||||
|
let mut forged = address.clone();
|
||||||
|
forged[field] = json!("foreign");
|
||||||
|
assert!(serde_json::from_value::<PluginDataAddress>(forged).is_err());
|
||||||
|
}
|
||||||
|
let mut numeric_version = address;
|
||||||
|
numeric_version["collection"]["schema_version"] = json!(1);
|
||||||
|
assert!(serde_json::from_value::<PluginDataAddress>(numeric_version).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,40 @@ pub enum PluginHostRequest {
|
|||||||
invocation_id: String,
|
invocation_id: String,
|
||||||
entry: PluginLogEntry,
|
entry: PluginLogEntry,
|
||||||
},
|
},
|
||||||
|
ResolveTicket {
|
||||||
|
invocation_id: String,
|
||||||
|
ticket_ref: String,
|
||||||
|
access: crate::PluginTicketAccess,
|
||||||
|
},
|
||||||
|
ReadTicket {
|
||||||
|
invocation_id: String,
|
||||||
|
ticket_id: String,
|
||||||
|
},
|
||||||
|
DataGet {
|
||||||
|
invocation_id: String,
|
||||||
|
ticket_id: String,
|
||||||
|
address: crate::PluginDataAddress,
|
||||||
|
},
|
||||||
|
DataMutate {
|
||||||
|
invocation_id: String,
|
||||||
|
ticket_id: String,
|
||||||
|
address: crate::PluginDataAddress,
|
||||||
|
operation_key: String,
|
||||||
|
mutation: crate::PluginDataMutation,
|
||||||
|
},
|
||||||
|
DataList {
|
||||||
|
invocation_id: String,
|
||||||
|
ticket_id: String,
|
||||||
|
collection: crate::PluginDataCollection,
|
||||||
|
cursor: Option<String>,
|
||||||
|
limit: u32,
|
||||||
|
},
|
||||||
|
DataLookupMutation {
|
||||||
|
invocation_id: String,
|
||||||
|
ticket_id: String,
|
||||||
|
address: crate::PluginDataAddress,
|
||||||
|
operation_key: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
@@ -116,6 +150,24 @@ pub enum PluginHostResponse {
|
|||||||
response: PluginHttpResponse,
|
response: PluginHttpResponse,
|
||||||
},
|
},
|
||||||
Logged,
|
Logged,
|
||||||
|
TicketResolved {
|
||||||
|
ticket_id: String,
|
||||||
|
},
|
||||||
|
TicketRead {
|
||||||
|
snapshot: crate::PluginTicketSnapshot,
|
||||||
|
},
|
||||||
|
DataCell {
|
||||||
|
cell: crate::PluginDataCell,
|
||||||
|
},
|
||||||
|
DataMutated {
|
||||||
|
receipt: crate::PluginDataReceipt,
|
||||||
|
},
|
||||||
|
DataListed {
|
||||||
|
page: crate::PluginDataPage,
|
||||||
|
},
|
||||||
|
DataMutationLookup {
|
||||||
|
outcome: crate::PluginDataMutationLookup,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
|||||||
@@ -7,6 +7,18 @@ pub fn plugin_host_typescript() -> String {
|
|||||||
let declarations = [
|
let declarations = [
|
||||||
PluginHttpMethod::decl(&config),
|
PluginHttpMethod::decl(&config),
|
||||||
PluginConsumer::decl(&config),
|
PluginConsumer::decl(&config),
|
||||||
|
PluginTicketAccess::decl(&config),
|
||||||
|
PluginTicketCaseState::decl(&config),
|
||||||
|
PluginTicketSnapshot::decl(&config),
|
||||||
|
PluginDataCollection::decl(&config),
|
||||||
|
PluginDataAddress::decl(&config),
|
||||||
|
PluginDataExpectation::decl(&config),
|
||||||
|
PluginDataCell::decl(&config),
|
||||||
|
PluginDataMutation::decl(&config),
|
||||||
|
PluginDataReceipt::decl(&config),
|
||||||
|
PluginDataMutationLookup::decl(&config),
|
||||||
|
PluginDataEntry::decl(&config),
|
||||||
|
PluginDataPage::decl(&config),
|
||||||
PluginHostRequest::decl(&config),
|
PluginHostRequest::decl(&config),
|
||||||
PluginHostOperation::decl(&config),
|
PluginHostOperation::decl(&config),
|
||||||
PluginHttpRequest::decl(&config),
|
PluginHttpRequest::decl(&config),
|
||||||
@@ -39,6 +51,10 @@ pub fn plugin_api_typescript() -> String {
|
|||||||
PluginHttpMethod::decl(&config),
|
PluginHttpMethod::decl(&config),
|
||||||
PluginConnectionMetadata::decl(&config),
|
PluginConnectionMetadata::decl(&config),
|
||||||
PluginGrantMetadata::decl(&config),
|
PluginGrantMetadata::decl(&config),
|
||||||
|
PluginDataSchemaMetadata::decl(&config),
|
||||||
|
PluginDataPurgeState::decl(&config),
|
||||||
|
PluginDataPurgeRequest::decl(&config),
|
||||||
|
PluginDataPurgeResponse::decl(&config),
|
||||||
PluginArtifact::decl(&config),
|
PluginArtifact::decl(&config),
|
||||||
PluginUploadResponse::decl(&config),
|
PluginUploadResponse::decl(&config),
|
||||||
PluginInstallation::decl(&config),
|
PluginInstallation::decl(&config),
|
||||||
|
|||||||
@@ -67,6 +67,29 @@ pub async fn materialize(
|
|||||||
plugin_plan::PluginGrant::Https { slot } => {
|
plugin_plan::PluginGrant::Https { slot } => {
|
||||||
grants.connection_slots.insert(slot.clone());
|
grants.connection_slots.insert(slot.clone());
|
||||||
}
|
}
|
||||||
|
plugin_plan::PluginGrant::TicketRead => grants.ticket_read = true,
|
||||||
|
plugin_plan::PluginGrant::TicketDataRead {
|
||||||
|
collection,
|
||||||
|
schema_id,
|
||||||
|
schema_version,
|
||||||
|
} => {
|
||||||
|
grants.ticket_data_read.insert((
|
||||||
|
collection.clone(),
|
||||||
|
schema_id.clone(),
|
||||||
|
schema_version.clone(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
plugin_plan::PluginGrant::TicketDataWrite {
|
||||||
|
collection,
|
||||||
|
schema_id,
|
||||||
|
schema_version,
|
||||||
|
} => {
|
||||||
|
grants.ticket_data_write.insert((
|
||||||
|
collection.clone(),
|
||||||
|
schema_id.clone(),
|
||||||
|
schema_version.clone(),
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pins.push(worker::plugin::PluginToolPin {
|
pins.push(worker::plugin::PluginToolPin {
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ impl Fixture {
|
|||||||
tools: serde_json::from_value(serde_json::to_value(m.tools).unwrap()).unwrap(),
|
tools: serde_json::from_value(serde_json::to_value(m.tools).unwrap()).unwrap(),
|
||||||
connections: vec![],
|
connections: vec![],
|
||||||
requested_grants: vec![],
|
requested_grants: vec![],
|
||||||
|
data_collections: vec![],
|
||||||
config_schema: PluginSchemaContribution {
|
config_schema: PluginSchemaContribution {
|
||||||
identity: format!("{}/config", m.id),
|
identity: format!("{}/config", m.id),
|
||||||
version: m.version.clone(),
|
version: m.version.clone(),
|
||||||
|
|||||||
@@ -517,8 +517,15 @@ fn tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receip
|
|||||||
"Subject Ticket responsibility and current-body notification routing"
|
"Subject Ticket responsibility and current-body notification routing"
|
||||||
.into()
|
.into()
|
||||||
),
|
),
|
||||||
(109, "Durable Plugin Host invocations and HTTPS effect receipts".into()),
|
(
|
||||||
(110, "Worker Plugin launch selection associations".into())
|
109,
|
||||||
|
"Durable Plugin Host invocations and HTTPS effect receipts".into()
|
||||||
|
),
|
||||||
|
(110, "Worker Plugin launch selection associations".into()),
|
||||||
|
(
|
||||||
|
111,
|
||||||
|
"Typed Plugin Ticket extension data and atomic mutation receipts".into()
|
||||||
|
)
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
for (table, rows) in retained.iter().zip(original.iter()) {
|
for (table, rows) in retained.iter().zip(original.iter()) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ pub mod memory_staging;
|
|||||||
pub mod observation;
|
pub mod observation;
|
||||||
pub mod plugin_binding;
|
pub mod plugin_binding;
|
||||||
pub mod plugin_catalog;
|
pub mod plugin_catalog;
|
||||||
|
pub mod plugin_data;
|
||||||
pub mod plugin_delivery;
|
pub mod plugin_delivery;
|
||||||
pub mod plugin_host;
|
pub mod plugin_host;
|
||||||
pub mod plugin_https;
|
pub mod plugin_https;
|
||||||
|
|||||||
@@ -88,8 +88,19 @@ pub(crate) fn migrate(conn: &Connection) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
pub(crate) fn verify(conn: &Connection) -> Result<()> {
|
pub(crate) fn verify(conn: &Connection) -> Result<()> {
|
||||||
|
verify_catalog_schema(conn, false)
|
||||||
|
}
|
||||||
|
/// Latest-schema verification after the separately registered typed-data
|
||||||
|
/// migration. Historical migration preconditions continue to use `verify`.
|
||||||
|
pub(crate) fn verify_with_data(conn: &Connection) -> Result<()> {
|
||||||
|
verify_catalog_schema(conn, true)
|
||||||
|
}
|
||||||
|
fn verify_catalog_schema(conn: &Connection, with_data: bool) -> Result<()> {
|
||||||
let expected = Connection::open_in_memory()?;
|
let expected = Connection::open_in_memory()?;
|
||||||
expected.execute_batch(include_str!("plugin_catalog_schema.sql"))?;
|
expected.execute_batch(include_str!("plugin_catalog_schema.sql"))?;
|
||||||
|
if with_data {
|
||||||
|
expected.execute_batch(crate::plugin_data::REFERENCE_MIGRATION_SQL)?;
|
||||||
|
}
|
||||||
let objects = expected.prepare("SELECT name,sql FROM sqlite_schema WHERE sql IS NOT NULL AND tbl_name LIKE 'plugin_%' ORDER BY name")?
|
let objects = expected.prepare("SELECT name,sql FROM sqlite_schema WHERE sql IS NOT NULL AND tbl_name LIKE 'plugin_%' ORDER BY name")?
|
||||||
.query_map([], |r| Ok((r.get::<_,String>(0)?, r.get::<_,String>(1)?)))?
|
.query_map([], |r| Ok((r.get::<_,String>(0)?, r.get::<_,String>(1)?)))?
|
||||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
@@ -249,6 +260,7 @@ fn artifact_metadata(
|
|||||||
.collect(),
|
.collect(),
|
||||||
connections: decode(&encode(&m.connections)?)?,
|
connections: decode(&encode(&m.connections)?)?,
|
||||||
requested_grants: decode(&encode(&m.requested_grants)?)?,
|
requested_grants: decode(&encode(&m.requested_grants)?)?,
|
||||||
|
data_collections: decode(&encode(&m.data_collections)?)?,
|
||||||
config_schema: PluginSchemaContribution {
|
config_schema: PluginSchemaContribution {
|
||||||
identity: schema.identity,
|
identity: schema.identity,
|
||||||
version: schema.version,
|
version: schema.version,
|
||||||
@@ -369,12 +381,79 @@ fn save_operation(
|
|||||||
tx.execute("INSERT INTO plugin_operations(workspace_id,operation_id,actor,kind,operation_key,fingerprint,result_json) VALUES(?1,?2,?3,?4,?5,?6,?7)",params![workspace,operation,actor,kind_name(kind),key,fingerprint,encode(&result)?])?;
|
tx.execute("INSERT INTO plugin_operations(workspace_id,operation_id,actor,kind,operation_key,fingerprint,result_json) VALUES(?1,?2,?3,?4,?5,?6,?7)",params![workspace,operation,actor,kind_name(kind),key,fingerprint,encode(&result)?])?;
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
// Collection/schema-id/version is a durable namespace within one installation.
|
||||||
|
// Revocation does not erase that contract: a later artifact must use a new schema
|
||||||
|
// version instead of reinterpreting retained cells under a changed schema.
|
||||||
|
fn canonical_schema(value: &serde_json::Value) -> Result<String> {
|
||||||
|
fn sorted(value: &serde_json::Value) -> serde_json::Value {
|
||||||
|
match value {
|
||||||
|
serde_json::Value::Object(map) => {
|
||||||
|
let ordered: std::collections::BTreeMap<_, _> = map
|
||||||
|
.iter()
|
||||||
|
.map(|(key, value)| (key.clone(), sorted(value)))
|
||||||
|
.collect();
|
||||||
|
serde_json::Value::Object(ordered.into_iter().collect())
|
||||||
|
}
|
||||||
|
serde_json::Value::Array(values) => {
|
||||||
|
serde_json::Value::Array(values.iter().map(sorted).collect())
|
||||||
|
}
|
||||||
|
value => value.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
encode(&sorted(value))
|
||||||
|
}
|
||||||
|
fn compare_data_schemas(
|
||||||
|
tx: &Transaction<'_>,
|
||||||
|
workspace: &str,
|
||||||
|
installation: &str,
|
||||||
|
artifact: &PluginArtifact,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut incoming = std::collections::BTreeMap::new();
|
||||||
|
for schema in &artifact.data_collections {
|
||||||
|
let address = (
|
||||||
|
schema.collection.clone(),
|
||||||
|
schema.schema_id.clone(),
|
||||||
|
schema.schema_version.clone(),
|
||||||
|
);
|
||||||
|
let canonical = canonical_schema(&schema.schema)?;
|
||||||
|
if let Some(previous) = incoming.insert(address, canonical.clone()) {
|
||||||
|
if previous != canonical {
|
||||||
|
return fail(PluginCatalogError::Conflict);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if incoming.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
// No revoked filter: the schema identity outlives execution authority.
|
||||||
|
let mut statement = tx.prepare(
|
||||||
|
"SELECT metadata_json FROM plugin_artifacts WHERE workspace_id=?1 AND installation_id=?2",
|
||||||
|
)?;
|
||||||
|
let mut rows = statement.query(params![workspace, installation])?;
|
||||||
|
while let Some(row) = rows.next()? {
|
||||||
|
let registered: PluginArtifact = decode(&row.get::<_, String>(0)?)?;
|
||||||
|
for schema in registered.data_collections {
|
||||||
|
let address = (
|
||||||
|
schema.collection.clone(),
|
||||||
|
schema.schema_id.clone(),
|
||||||
|
schema.schema_version.clone(),
|
||||||
|
);
|
||||||
|
if let Some(candidate) = incoming.get(&address) {
|
||||||
|
if *candidate != canonical_schema(&schema.schema)? {
|
||||||
|
return fail(PluginCatalogError::Conflict);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
fn add_artifact(
|
fn add_artifact(
|
||||||
tx: &Transaction<'_>,
|
tx: &Transaction<'_>,
|
||||||
workspace: &str,
|
workspace: &str,
|
||||||
installation: &str,
|
installation: &str,
|
||||||
artifact: &PluginArtifact,
|
artifact: &PluginArtifact,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
compare_data_schemas(tx, workspace, installation, artifact)?;
|
||||||
let existing=tx.query_row("SELECT artifact_digest,package_digest,revoked FROM plugin_artifacts WHERE workspace_id=?1 AND installation_id=?2 AND version=?3",params![workspace,installation,artifact.version],|r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?,r.get::<_,bool>(2)?))).optional()?;
|
let existing=tx.query_row("SELECT artifact_digest,package_digest,revoked FROM plugin_artifacts WHERE workspace_id=?1 AND installation_id=?2 AND version=?3",params![workspace,installation,artifact.version],|r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?,r.get::<_,bool>(2)?))).optional()?;
|
||||||
if let Some((old, package, revoked)) = existing {
|
if let Some((old, package, revoked)) = existing {
|
||||||
if old != artifact.artifact_digest || package != artifact.package_digest || revoked {
|
if old != artifact.artifact_digest || package != artifact.package_digest || revoked {
|
||||||
@@ -660,6 +739,8 @@ pub enum PluginReferenceKind {
|
|||||||
Pending,
|
Pending,
|
||||||
Active,
|
Active,
|
||||||
Restorable,
|
Restorable,
|
||||||
|
/// Retained typed extension data, including delete tombstones.
|
||||||
|
Data,
|
||||||
}
|
}
|
||||||
impl PluginReferenceKind {
|
impl PluginReferenceKind {
|
||||||
fn name(self) -> &'static str {
|
fn name(self) -> &'static str {
|
||||||
@@ -668,6 +749,7 @@ impl PluginReferenceKind {
|
|||||||
Self::Pending => "pending",
|
Self::Pending => "pending",
|
||||||
Self::Active => "active",
|
Self::Active => "active",
|
||||||
Self::Restorable => "restorable",
|
Self::Restorable => "restorable",
|
||||||
|
Self::Data => "data",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -729,5 +811,9 @@ pub fn plugin_reference_remove(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "plugin_data/catalog_tests.rs"]
|
||||||
|
mod data_schema_tests;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ fn plugin_catalog_migration_matches_fresh_schema_from_oldest_retained_and_105()
|
|||||||
// Plugin catalog remains migration 106 even when startup applies later steps.
|
// Plugin catalog remains migration 106 even when startup applies later steps.
|
||||||
assert_eq!(after.iter().filter(|(version, _)| *version == 106).cloned().collect::<Vec<_>>(),
|
assert_eq!(after.iter().filter(|(version, _)| *version == 106).cloned().collect::<Vec<_>>(),
|
||||||
vec![(106, crate::plugin_catalog::MIGRATION_NAME.into())]);
|
vec![(106, crate::plugin_catalog::MIGRATION_NAME.into())]);
|
||||||
assert_eq!(after.last().unwrap(), &(110, "Worker Plugin launch selection associations".into()));
|
assert_eq!(after.last().unwrap(), &(111, "Typed Plugin Ticket extension data and atomic mutation receipts".into()));
|
||||||
assert_eq!(current_schema_version(c)?, LATEST_SCHEMA_VERSION);
|
assert_eq!(current_schema_version(c)?, LATEST_SCHEMA_VERSION);
|
||||||
assert_eq!(c.query_row("SELECT display_name FROM workspaces WHERE workspace_id='plugin-workspace'",[],|r|r.get::<_,String>(0))?,"Plugins");
|
assert_eq!(c.query_row("SELECT display_name FROM workspaces WHERE workspace_id='plugin-workspace'",[],|r|r.get::<_,String>(0))?,"Plugins");
|
||||||
assert_eq!(c.query_row("SELECT count(*) FROM pragma_foreign_key_check",[],|r|r.get::<_,i64>(0))?,0);
|
assert_eq!(c.query_row("SELECT count(*) FROM pragma_foreign_key_check",[],|r|r.get::<_,i64>(0))?,0);
|
||||||
@@ -77,8 +77,12 @@ fn plugin_catalog_migration_marker_failure_rolls_back_then_retries_without_chang
|
|||||||
"Durable Plugin Host invocations and HTTPS effect receipts".into(),
|
"Durable Plugin Host invocations and HTTPS effect receipts".into(),
|
||||||
));
|
));
|
||||||
expected.push((110, "Worker Plugin launch selection associations".into()));
|
expected.push((110, "Worker Plugin launch selection associations".into()));
|
||||||
|
expected.push((
|
||||||
|
111,
|
||||||
|
"Typed Plugin Ticket extension data and atomic mutation receipts".into(),
|
||||||
|
));
|
||||||
assert_eq!(workspace_schema_migration_history(c)?, expected);
|
assert_eq!(workspace_schema_migration_history(c)?, expected);
|
||||||
crate::plugin_catalog::verify(c)?;
|
crate::plugin_catalog::verify_with_data(c)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -0,0 +1,600 @@
|
|||||||
|
//! Transactional Ticket extension cells, not an authorization or schema authority.
|
||||||
|
//!
|
||||||
|
//! The owner must reauthorize the exact object/collection on every call (including
|
||||||
|
//! receipt replay), validate values against the pinned closed schema, and supply
|
||||||
|
//! canonical Ticket and catalog identities. Use the Ticket owner's SQLite DB and
|
||||||
|
//! an IMMEDIATE transaction; commit before reporting success. No guest identity,
|
||||||
|
//! SQL, migration or cross-object transaction is exposed by this module.
|
||||||
|
//!
|
||||||
|
//! Cells and delete tombstones survive Ticket deletion and revocation. A parent
|
||||||
|
//! existence check under write ownership plus SQL triggers fences deletion; the
|
||||||
|
//! Workspace `deleting` state is also fenced. Explicit admin purge first requires
|
||||||
|
//! all artifacts revoked, then compares the ordered exact cell/last-write set.
|
||||||
|
use crate::{
|
||||||
|
Error, Result,
|
||||||
|
plugin_catalog::{self, PluginReference, PluginReferenceKind},
|
||||||
|
plugin_receipt::{self, InvocationRecord},
|
||||||
|
};
|
||||||
|
use rusqlite::{Connection, OptionalExtension, Transaction, params};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use server_api::{
|
||||||
|
PluginDataAddress, PluginDataCell, PluginDataCollection, PluginDataEntry,
|
||||||
|
PluginDataExpectation, PluginDataMutation, PluginDataMutationLookup, PluginDataPage,
|
||||||
|
PluginDataReceipt, PluginEffectObservation, PluginHostError, PluginHostErrorCode,
|
||||||
|
};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
pub const SCHEMA_SQL: &str = include_str!("plugin_data/schema.sql");
|
||||||
|
/// Apply exactly once in a new ordered migration, before `SCHEMA_SQL`.
|
||||||
|
pub const REFERENCE_MIGRATION_SQL: &str = include_str!("plugin_data/reference_migration.sql");
|
||||||
|
const CURSOR_LIFETIME_MS: i64 = 10 * 60 * 1000;
|
||||||
|
const MAX_CURSORS_PER_WORKSPACE: i64 = 4096;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct Namespace {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub installation_id: String,
|
||||||
|
pub ticket_id: String,
|
||||||
|
pub collection: PluginDataCollection,
|
||||||
|
}
|
||||||
|
fn fail<T>(code: PluginHostErrorCode) -> Result<T> {
|
||||||
|
// Key/identity conflicts and expired/cancelled invocations can have an older
|
||||||
|
// committed mutation. Match the shared receipt owner's conservative signal.
|
||||||
|
let effect = match code {
|
||||||
|
PluginHostErrorCode::Conflict
|
||||||
|
| PluginHostErrorCode::ReceiptExpired
|
||||||
|
| PluginHostErrorCode::Cancelled => PluginEffectObservation::Indeterminate,
|
||||||
|
_ => PluginEffectObservation::NotDispatched,
|
||||||
|
};
|
||||||
|
Err(PluginHostError {
|
||||||
|
code,
|
||||||
|
effect,
|
||||||
|
diagnostic_id: uuid::Uuid::now_v7().to_string(),
|
||||||
|
}
|
||||||
|
.into())
|
||||||
|
}
|
||||||
|
fn valid(value: &str, max: usize) -> bool {
|
||||||
|
!value.is_empty() && value.len() <= max && !value.chars().any(char::is_control)
|
||||||
|
}
|
||||||
|
fn encode(value: &impl Serialize) -> Result<String> {
|
||||||
|
serde_json::to_string(value).map_err(|_| Error::Store("invalid Plugin data encoding".into()))
|
||||||
|
}
|
||||||
|
fn decode<T: serde::de::DeserializeOwned>(value: &str) -> Result<T> {
|
||||||
|
serde_json::from_str(value).map_err(|_| Error::Store("invalid persisted Plugin data".into()))
|
||||||
|
}
|
||||||
|
fn hex(bytes: &[u8]) -> String {
|
||||||
|
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||||
|
}
|
||||||
|
fn hash(bytes: &[u8]) -> String {
|
||||||
|
format!("sha256:{}", hex(&Sha256::digest(bytes)))
|
||||||
|
}
|
||||||
|
// Recursion is bounded by serde_json's parser; objects are explicitly sorted even
|
||||||
|
// if another dependency enables serde_json/preserve_order through feature union.
|
||||||
|
fn canonical(value: serde_json::Value) -> serde_json::Value {
|
||||||
|
match value {
|
||||||
|
serde_json::Value::Object(map) => {
|
||||||
|
let sorted: std::collections::BTreeMap<_, _> = map
|
||||||
|
.into_iter()
|
||||||
|
.map(|(key, value)| (key, canonical(value)))
|
||||||
|
.collect();
|
||||||
|
serde_json::Value::Object(sorted.into_iter().collect())
|
||||||
|
}
|
||||||
|
serde_json::Value::Array(values) => {
|
||||||
|
serde_json::Value::Array(values.into_iter().map(canonical).collect())
|
||||||
|
}
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn canonical_json(value: &str) -> Result<String> {
|
||||||
|
if value.len() > server_api::PLUGIN_DATA_VALUE_LIMIT {
|
||||||
|
return fail(PluginHostErrorCode::QuotaExceeded);
|
||||||
|
}
|
||||||
|
let value = serde_json::from_str(value).map_err(|_| {
|
||||||
|
Error::from(PluginHostError {
|
||||||
|
code: PluginHostErrorCode::InvalidInput,
|
||||||
|
effect: PluginEffectObservation::NotDispatched,
|
||||||
|
diagnostic_id: uuid::Uuid::now_v7().to_string(),
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
let encoded = encode(&canonical(value))?;
|
||||||
|
if encoded.len() > server_api::PLUGIN_DATA_VALUE_LIMIT {
|
||||||
|
return fail(PluginHostErrorCode::QuotaExceeded);
|
||||||
|
}
|
||||||
|
Ok(encoded)
|
||||||
|
}
|
||||||
|
fn digest(value: &impl Serialize) -> Result<String> {
|
||||||
|
let value =
|
||||||
|
serde_json::to_value(value).map_err(|_| Error::Store("invalid Plugin data".into()))?;
|
||||||
|
Ok(hash(encode(&canonical(value))?.as_bytes()))
|
||||||
|
}
|
||||||
|
fn validate_namespace(ns: &Namespace) -> Result<()> {
|
||||||
|
if !valid(&ns.workspace_id, 512)
|
||||||
|
|| !valid(&ns.installation_id, 512)
|
||||||
|
|| !valid(&ns.ticket_id, 512)
|
||||||
|
|| !valid(&ns.collection.collection, 128)
|
||||||
|
|| !valid(&ns.collection.schema_id, 128)
|
||||||
|
|| !valid(&ns.collection.schema_version, 128)
|
||||||
|
{
|
||||||
|
return fail(PluginHostErrorCode::InvalidInput);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn parent(conn: &Connection, ns: &Namespace) -> Result<()> {
|
||||||
|
validate_namespace(ns)?;
|
||||||
|
if !conn.query_row("SELECT EXISTS(SELECT 1 FROM typed_tickets t JOIN workspaces w USING(workspace_id) JOIN plugin_installations i ON i.workspace_id=t.workspace_id WHERE t.workspace_id=?1 AND t.ticket_id=?2 AND w.state='active' AND i.installation_id=?3 AND i.uninstalled=0)",params![ns.workspace_id,ns.ticket_id,ns.installation_id], |row| row.get::<_,bool>(0))? {
|
||||||
|
return fail(PluginHostErrorCode::NotFound);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn invocation(
|
||||||
|
conn: &Connection,
|
||||||
|
record: &InvocationRecord,
|
||||||
|
ns: &Namespace,
|
||||||
|
now_ms: i64,
|
||||||
|
) -> Result<InvocationRecord> {
|
||||||
|
parent(conn, ns)?;
|
||||||
|
let record = plugin_receipt::verify_record(conn, record)?;
|
||||||
|
if record.workspace_id != ns.workspace_id || record.installation_id != ns.installation_id {
|
||||||
|
return fail(PluginHostErrorCode::Conflict);
|
||||||
|
}
|
||||||
|
if now_ms < 0 {
|
||||||
|
return fail(PluginHostErrorCode::InvalidInput);
|
||||||
|
}
|
||||||
|
Ok(record)
|
||||||
|
}
|
||||||
|
fn expired(record: &InvocationRecord, now_ms: i64) -> bool {
|
||||||
|
record.expired || now_ms >= record.expires_at_ms
|
||||||
|
}
|
||||||
|
fn active(record: &InvocationRecord, now_ms: i64) -> Result<()> {
|
||||||
|
if expired(record, now_ms) {
|
||||||
|
return fail(PluginHostErrorCode::ReceiptExpired);
|
||||||
|
}
|
||||||
|
if record.cancelled {
|
||||||
|
return fail(PluginHostErrorCode::Cancelled);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn validate_key(key: &str) -> Result<()> {
|
||||||
|
if !valid(key, 256) {
|
||||||
|
return fail(PluginHostErrorCode::InvalidInput);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn cell(conn: &Connection, ns: &Namespace, key: &str) -> Result<PluginDataCell> {
|
||||||
|
let saved = conn.query_row("SELECT last_write,value_json FROM plugin_data_cells WHERE workspace_id=?1 AND installation_id=?2 AND ticket_id=?3 AND collection=?4 AND schema_id=?5 AND schema_version=?6 AND cell_key=?7",
|
||||||
|
params![ns.workspace_id,ns.installation_id,ns.ticket_id,ns.collection.collection,ns.collection.schema_id,ns.collection.schema_version,key],
|
||||||
|
|row| Ok((row.get::<_,String>(0)?,row.get::<_,Option<String>>(1)?))).optional()?;
|
||||||
|
Ok(match saved {
|
||||||
|
None => PluginDataCell::NeverWritten,
|
||||||
|
Some((last_write, Some(value_json))) => PluginDataCell::Present {
|
||||||
|
value_json,
|
||||||
|
last_write,
|
||||||
|
},
|
||||||
|
Some((last_delete, None)) => PluginDataCell::Deleted { last_delete },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/// Current read, never a receipt replay. Parent must check the exact read grant.
|
||||||
|
pub fn get(conn: &Connection, ns: &Namespace, key: &str) -> Result<PluginDataCell> {
|
||||||
|
parent(conn, ns)?;
|
||||||
|
validate_key(key)?;
|
||||||
|
cell(conn, ns, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist only a bounded selector, never authorization or a snapshot of cells.
|
||||||
|
// The 256-bit CSPRNG token cannot be forged by editing namespace/offset/deadline.
|
||||||
|
fn issue_cursor(
|
||||||
|
conn: &Connection,
|
||||||
|
ns: &Namespace,
|
||||||
|
scope: &str,
|
||||||
|
after: &str,
|
||||||
|
deadline: i64,
|
||||||
|
) -> Result<String> {
|
||||||
|
use ring::rand::{SecureRandom, SystemRandom};
|
||||||
|
if let Some(token) = conn.query_row("SELECT cursor_token FROM plugin_data_cursors WHERE workspace_id=?1 AND namespace_digest=?2 AND after_key=?3 AND expires_at_ms=?4",params![ns.workspace_id,scope,after,deadline],|row|row.get(0)).optional()? {
|
||||||
|
return Ok(token);
|
||||||
|
}
|
||||||
|
let mut bytes = [0u8; 32];
|
||||||
|
SystemRandom::new()
|
||||||
|
.fill(&mut bytes)
|
||||||
|
.map_err(|_| Error::Store("Plugin data cursor entropy unavailable".into()))?;
|
||||||
|
let token = format!("pdc-{}", hex(&bytes));
|
||||||
|
// The bounded INSERT predicate is evaluated under SQLite write ownership,
|
||||||
|
// even when tests call list on a Connection rather than the owner's tx.
|
||||||
|
let changed = conn.execute("INSERT INTO plugin_data_cursors(workspace_id,installation_id,cursor_token,namespace_digest,after_key,expires_at_ms) SELECT ?1,?2,?3,?4,?5,?6 WHERE (SELECT count(*) FROM plugin_data_cursors WHERE workspace_id=?1)<?7 ON CONFLICT(workspace_id,namespace_digest,after_key,expires_at_ms) DO NOTHING",params![ns.workspace_id,ns.installation_id,token,scope,after,deadline,MAX_CURSORS_PER_WORKSPACE])?;
|
||||||
|
if changed == 1 {
|
||||||
|
return Ok(token);
|
||||||
|
}
|
||||||
|
match conn.query_row("SELECT cursor_token FROM plugin_data_cursors WHERE workspace_id=?1 AND namespace_digest=?2 AND after_key=?3 AND expires_at_ms=?4",params![ns.workspace_id,scope,after,deadline],|row|row.get(0)).optional()? {
|
||||||
|
Some(token)=>Ok(token),None=>fail(PluginHostErrorCode::QuotaExceeded),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// Stable bytewise key order including tombstones, not a cross-page snapshot.
|
||||||
|
/// Opaque persisted cursors bind namespace/offset/expiry, not access authority.
|
||||||
|
/// Current authorization is required; the owner commits cursor issuance in its
|
||||||
|
/// IMMEDIATE transaction before returning a page. Expired selectors are pruned.
|
||||||
|
pub fn list(
|
||||||
|
conn: &Connection,
|
||||||
|
ns: &Namespace,
|
||||||
|
cursor: Option<&str>,
|
||||||
|
limit: u32,
|
||||||
|
now_ms: i64,
|
||||||
|
) -> Result<PluginDataPage> {
|
||||||
|
parent(conn, ns)?;
|
||||||
|
if !(1..=server_api::PLUGIN_DATA_LIST_LIMIT).contains(&limit) || now_ms < 0 {
|
||||||
|
return fail(PluginHostErrorCode::InvalidInput);
|
||||||
|
}
|
||||||
|
let scope = digest(ns)?;
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM plugin_data_cursors WHERE workspace_id=?1 AND expires_at_ms<=?2",
|
||||||
|
params![ns.workspace_id, now_ms],
|
||||||
|
)?;
|
||||||
|
let (after, deadline) = match cursor {
|
||||||
|
Some(raw) => {
|
||||||
|
if raw.len() != 68
|
||||||
|
|| !raw.starts_with("pdc-")
|
||||||
|
|| !raw[4..]
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
||||||
|
{
|
||||||
|
return fail(PluginHostErrorCode::InvalidInput);
|
||||||
|
}
|
||||||
|
let saved = conn.query_row("SELECT after_key,expires_at_ms FROM plugin_data_cursors WHERE workspace_id=?1 AND installation_id=?2 AND namespace_digest=?3 AND cursor_token=?4 AND expires_at_ms>?5",params![ns.workspace_id,ns.installation_id,scope,raw,now_ms],|row|Ok((row.get::<_,String>(0)?,row.get::<_,i64>(1)?))).optional()?;
|
||||||
|
match saved {
|
||||||
|
Some(value) => value,
|
||||||
|
None => return fail(PluginHostErrorCode::InvalidInput),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => (
|
||||||
|
String::new(),
|
||||||
|
now_ms
|
||||||
|
.checked_add(CURSOR_LIFETIME_MS)
|
||||||
|
.ok_or_else(|| Error::InvalidInput("Plugin cursor time".into()))?,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let mut stmt = conn.prepare("SELECT cell_key,last_write,value_json FROM plugin_data_cells WHERE workspace_id=?1 AND installation_id=?2 AND ticket_id=?3 AND collection=?4 AND schema_id=?5 AND schema_version=?6 AND cell_key>?7 COLLATE BINARY ORDER BY cell_key COLLATE BINARY LIMIT ?8")?;
|
||||||
|
let mut entries = stmt
|
||||||
|
.query_map(
|
||||||
|
params![
|
||||||
|
ns.workspace_id,
|
||||||
|
ns.installation_id,
|
||||||
|
ns.ticket_id,
|
||||||
|
ns.collection.collection,
|
||||||
|
ns.collection.schema_id,
|
||||||
|
ns.collection.schema_version,
|
||||||
|
after,
|
||||||
|
i64::from(limit) + 1
|
||||||
|
],
|
||||||
|
|row| {
|
||||||
|
let key = row.get(0)?;
|
||||||
|
let last = row.get::<_, String>(1)?;
|
||||||
|
let value = row.get::<_, Option<String>>(2)?;
|
||||||
|
Ok(PluginDataEntry {
|
||||||
|
key,
|
||||||
|
cell: match value {
|
||||||
|
Some(value_json) => PluginDataCell::Present {
|
||||||
|
value_json,
|
||||||
|
last_write: last,
|
||||||
|
},
|
||||||
|
None => PluginDataCell::Deleted { last_delete: last },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
|
let next_cursor = if entries.len() > limit as usize {
|
||||||
|
entries.pop();
|
||||||
|
Some(issue_cursor(
|
||||||
|
conn,
|
||||||
|
ns,
|
||||||
|
&scope,
|
||||||
|
&entries.last().expect("nonzero limit").key,
|
||||||
|
deadline,
|
||||||
|
)?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
Ok(PluginDataPage {
|
||||||
|
entries,
|
||||||
|
next_cursor,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Saved {
|
||||||
|
namespace: String,
|
||||||
|
fingerprint: String,
|
||||||
|
receipt: PluginDataReceipt,
|
||||||
|
}
|
||||||
|
fn saved(
|
||||||
|
conn: &Connection,
|
||||||
|
record: &InvocationRecord,
|
||||||
|
operation_key: &str,
|
||||||
|
) -> Result<Option<Saved>> {
|
||||||
|
Ok(conn.query_row("SELECT namespace_json,fingerprint,mutation_id,result_digest FROM plugin_data_receipts WHERE workspace_id=?1 AND invocation_id=?2 AND operation_key=?3",
|
||||||
|
params![record.workspace_id,record.invocation_id,operation_key], |row| Ok(Saved{namespace:row.get(0)?,fingerprint:row.get(1)?,receipt:PluginDataReceipt{mutation_id:row.get(2)?,result_digest:row.get(3)?}})).optional()?)
|
||||||
|
}
|
||||||
|
fn http_key(conn: &Connection, record: &InvocationRecord, key: &str) -> Result<bool> {
|
||||||
|
Ok(conn.query_row("SELECT EXISTS(SELECT 1 FROM plugin_effect_receipts WHERE workspace_id=?1 AND invocation_id=?2 AND effect_key=?3)",params![record.workspace_id,record.invocation_id,key],|r|r.get(0))?)
|
||||||
|
}
|
||||||
|
fn address_json(ns: &Namespace, key: &str) -> Result<String> {
|
||||||
|
encode(&(ns, key))
|
||||||
|
}
|
||||||
|
/// Lookup is scoped to one exact address; transport/DB errors are not NotRecorded.
|
||||||
|
/// Expiry takes precedence over unused keys, as for the existing HTTPS receipts.
|
||||||
|
pub fn lookup_mutation(
|
||||||
|
conn: &Connection,
|
||||||
|
record: &InvocationRecord,
|
||||||
|
ns: &Namespace,
|
||||||
|
key: &str,
|
||||||
|
operation_key: &str,
|
||||||
|
now_ms: i64,
|
||||||
|
) -> Result<PluginDataMutationLookup> {
|
||||||
|
validate_key(key)?;
|
||||||
|
validate_key(operation_key)?;
|
||||||
|
let record = invocation(conn, record, ns, now_ms)?;
|
||||||
|
if expired(&record, now_ms) {
|
||||||
|
return Ok(PluginDataMutationLookup::Expired);
|
||||||
|
}
|
||||||
|
if record.cancelled {
|
||||||
|
return fail(PluginHostErrorCode::Cancelled);
|
||||||
|
}
|
||||||
|
if let Some(saved) = saved(conn, &record, operation_key)? {
|
||||||
|
if saved.namespace != address_json(ns, key)? {
|
||||||
|
return fail(PluginHostErrorCode::Conflict);
|
||||||
|
}
|
||||||
|
return Ok(PluginDataMutationLookup::Committed(saved.receipt));
|
||||||
|
}
|
||||||
|
if http_key(conn, &record, operation_key)? {
|
||||||
|
return fail(PluginHostErrorCode::Conflict);
|
||||||
|
}
|
||||||
|
Ok(PluginDataMutationLookup::NotRecorded)
|
||||||
|
}
|
||||||
|
fn atomic<T>(tx: &Transaction<'_>, work: impl FnOnce() -> Result<T>) -> Result<T> {
|
||||||
|
tx.execute_batch("SAVEPOINT plugin_data_atomic")?;
|
||||||
|
match work() {
|
||||||
|
Ok(value) => {
|
||||||
|
tx.execute_batch("RELEASE plugin_data_atomic")?;
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tx.execute_batch("ROLLBACK TO plugin_data_atomic; RELEASE plugin_data_atomic")?;
|
||||||
|
Err(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// Put/delete and receipt are atomic even if the caller catches an error and
|
||||||
|
/// commits unrelated work. `reference` is Host-admitted, never guest input.
|
||||||
|
/// Replay is checked before catalog/CAS and returns the original receipt only.
|
||||||
|
pub fn mutate(
|
||||||
|
tx: &Transaction<'_>,
|
||||||
|
record: &InvocationRecord,
|
||||||
|
ns: &Namespace,
|
||||||
|
key: &str,
|
||||||
|
operation_key: &str,
|
||||||
|
mutation: &PluginDataMutation,
|
||||||
|
reference: &PluginReference,
|
||||||
|
now_ms: i64,
|
||||||
|
) -> Result<PluginDataReceipt> {
|
||||||
|
validate_key(key)?;
|
||||||
|
validate_key(operation_key)?;
|
||||||
|
let record = invocation(tx, record, ns, now_ms)?;
|
||||||
|
active(&record, now_ms)?;
|
||||||
|
let mutation = match mutation {
|
||||||
|
PluginDataMutation::Put {
|
||||||
|
expected,
|
||||||
|
value_json,
|
||||||
|
} => PluginDataMutation::Put {
|
||||||
|
expected: expected.clone(),
|
||||||
|
value_json: canonical_json(value_json)?,
|
||||||
|
},
|
||||||
|
PluginDataMutation::Delete {
|
||||||
|
expected_last_write,
|
||||||
|
} => PluginDataMutation::Delete {
|
||||||
|
expected_last_write: expected_last_write.clone(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let address = address_json(ns, key)?;
|
||||||
|
let fingerprint = digest(&("ticket-data-mutation", ns, key, &mutation))?;
|
||||||
|
if let Some(saved) = saved(tx, &record, operation_key)? {
|
||||||
|
if saved.namespace != address || saved.fingerprint != fingerprint {
|
||||||
|
return fail(PluginHostErrorCode::Conflict);
|
||||||
|
}
|
||||||
|
return Ok(saved.receipt);
|
||||||
|
}
|
||||||
|
if http_key(tx, &record, operation_key)? {
|
||||||
|
return fail(PluginHostErrorCode::Conflict);
|
||||||
|
}
|
||||||
|
let expected = match &mutation {
|
||||||
|
PluginDataMutation::Put { expected, .. } => expected.clone(),
|
||||||
|
PluginDataMutation::Delete {
|
||||||
|
expected_last_write,
|
||||||
|
} => PluginDataExpectation::LastWrite(expected_last_write.clone()),
|
||||||
|
};
|
||||||
|
if let PluginDataExpectation::LastWrite(last) = &expected {
|
||||||
|
if !valid(last, 512) {
|
||||||
|
return fail(PluginHostErrorCode::InvalidInput);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
atomic(tx, || {
|
||||||
|
let observed = cell(tx, ns, key)?;
|
||||||
|
let matches = match (&expected, &observed) {
|
||||||
|
(PluginDataExpectation::NeverWritten, PluginDataCell::NeverWritten) => true,
|
||||||
|
(
|
||||||
|
PluginDataExpectation::LastWrite(want),
|
||||||
|
PluginDataCell::Present { last_write, .. },
|
||||||
|
) => want == last_write,
|
||||||
|
(PluginDataExpectation::LastWrite(want), PluginDataCell::Deleted { last_delete }) => {
|
||||||
|
want == last_delete
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
if !matches {
|
||||||
|
return fail(PluginHostErrorCode::Conflict);
|
||||||
|
}
|
||||||
|
if reference.installation_id != ns.installation_id {
|
||||||
|
return fail(PluginHostErrorCode::Conflict);
|
||||||
|
}
|
||||||
|
let owner = digest(ns)?;
|
||||||
|
let reference = PluginReference {
|
||||||
|
kind: PluginReferenceKind::Data,
|
||||||
|
owner: owner.clone(),
|
||||||
|
..reference.clone()
|
||||||
|
};
|
||||||
|
plugin_catalog::plugin_reference_put(tx, &ns.workspace_id, &reference)?;
|
||||||
|
tx.execute("INSERT INTO plugin_data_holders(workspace_id,installation_id,owner,artifact_digest) VALUES(?1,?2,?3,?4) ON CONFLICT DO NOTHING",params![ns.workspace_id,ns.installation_id,owner,reference.artifact_digest])?;
|
||||||
|
let mutation_id = uuid::Uuid::now_v7().to_string();
|
||||||
|
let value = match &mutation {
|
||||||
|
PluginDataMutation::Put { value_json, .. } => Some(value_json.as_str()),
|
||||||
|
PluginDataMutation::Delete { .. } => None,
|
||||||
|
};
|
||||||
|
tx.execute("INSERT INTO plugin_data_cells(workspace_id,installation_id,ticket_id,collection,schema_id,schema_version,cell_key,last_write,value_json) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9) ON CONFLICT(workspace_id,installation_id,ticket_id,collection,schema_id,schema_version,cell_key) DO UPDATE SET last_write=excluded.last_write,value_json=excluded.value_json",
|
||||||
|
params![ns.workspace_id,ns.installation_id,ns.ticket_id,ns.collection.collection,ns.collection.schema_id,ns.collection.schema_version,key,mutation_id,value])?;
|
||||||
|
let result = match value {
|
||||||
|
Some(value_json) => PluginDataCell::Present {
|
||||||
|
value_json: value_json.into(),
|
||||||
|
last_write: mutation_id.clone(),
|
||||||
|
},
|
||||||
|
None => PluginDataCell::Deleted {
|
||||||
|
last_delete: mutation_id.clone(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let receipt = PluginDataReceipt {
|
||||||
|
mutation_id,
|
||||||
|
result_digest: digest(&result)?,
|
||||||
|
};
|
||||||
|
tx.execute("INSERT INTO plugin_data_receipts(workspace_id,invocation_id,operation_key,namespace_json,fingerprint,mutation_id,result_digest,result_json) VALUES(?1,?2,?3,?4,?5,?6,?7,?8)",
|
||||||
|
params![ns.workspace_id,record.invocation_id,operation_key,address,fingerprint,receipt.mutation_id,receipt.result_digest,encode(&result)?])?;
|
||||||
|
Ok(receipt)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct PurgeSnapshot {
|
||||||
|
pub installation_last_operation: String,
|
||||||
|
pub cells_digest: String,
|
||||||
|
pub cell_count: u64,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct PurgeReceipt {
|
||||||
|
pub purge_id: String,
|
||||||
|
pub observed: PurgeSnapshot,
|
||||||
|
}
|
||||||
|
fn purge_fenced(conn: &Connection, workspace: &str, installation: &str) -> Result<String> {
|
||||||
|
if !valid(workspace, 512) || !valid(installation, 512) {
|
||||||
|
return fail(PluginHostErrorCode::InvalidInput);
|
||||||
|
}
|
||||||
|
let operation = conn.query_row("SELECT i.last_operation FROM plugin_installations i JOIN workspaces w USING(workspace_id) WHERE i.workspace_id=?1 AND i.installation_id=?2 AND i.uninstalled=0 AND w.state='active' AND NOT EXISTS(SELECT 1 FROM plugin_artifacts a WHERE a.workspace_id=i.workspace_id AND a.installation_id=i.installation_id AND a.revoked=0)",params![workspace,installation],|row|row.get(0)).optional()?;
|
||||||
|
match operation {
|
||||||
|
Some(value) => Ok(value),
|
||||||
|
None => fail(PluginHostErrorCode::Conflict),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// Observe only after execution is fenced by catalog revocation. Hash a streamed,
|
||||||
|
/// ordered set of exact namespace/key/last-write/deleted tuples, not a generation.
|
||||||
|
/// Caller keeps the same IMMEDIATE transaction through this observation.
|
||||||
|
pub fn purge_snapshot(
|
||||||
|
tx: &Transaction<'_>,
|
||||||
|
workspace: &str,
|
||||||
|
installation: &str,
|
||||||
|
) -> Result<PurgeSnapshot> {
|
||||||
|
let installation_last_operation = purge_fenced(tx, workspace, installation)?;
|
||||||
|
let mut stmt = tx.prepare("SELECT ticket_id,collection,schema_id,schema_version,cell_key,last_write,value_json IS NULL FROM plugin_data_cells WHERE workspace_id=?1 AND installation_id=?2 ORDER BY ticket_id COLLATE BINARY,collection COLLATE BINARY,schema_id COLLATE BINARY,schema_version COLLATE BINARY,cell_key COLLATE BINARY")?;
|
||||||
|
let mut rows = stmt.query(params![workspace, installation])?;
|
||||||
|
let mut hash = Sha256::new();
|
||||||
|
hash.update(b"ticket-data-purge-exact-set-v1\0");
|
||||||
|
hash.update(encode(&(workspace, installation))?.as_bytes());
|
||||||
|
let mut count = 0u64;
|
||||||
|
while let Some(row) = rows.next()? {
|
||||||
|
let tuple = (
|
||||||
|
row.get::<_, String>(0)?,
|
||||||
|
row.get::<_, String>(1)?,
|
||||||
|
row.get::<_, String>(2)?,
|
||||||
|
row.get::<_, String>(3)?,
|
||||||
|
row.get::<_, String>(4)?,
|
||||||
|
row.get::<_, String>(5)?,
|
||||||
|
row.get::<_, bool>(6)?,
|
||||||
|
);
|
||||||
|
let bytes = encode(&tuple)?;
|
||||||
|
hash.update((bytes.len() as u64).to_be_bytes());
|
||||||
|
hash.update(bytes.as_bytes());
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
Ok(PurgeSnapshot {
|
||||||
|
installation_last_operation,
|
||||||
|
cells_digest: format!("sha256:{}", hex(&hash.finalize())),
|
||||||
|
cell_count: count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/// Current Workspace-admin auth is external and required even for replay. Purge
|
||||||
|
/// receipts survive data removal and uninstall. No implicit uninstall or revoke.
|
||||||
|
pub fn purge(
|
||||||
|
tx: &Transaction<'_>,
|
||||||
|
workspace: &str,
|
||||||
|
installation: &str,
|
||||||
|
actor: &str,
|
||||||
|
operation_key: &str,
|
||||||
|
expected: &PurgeSnapshot,
|
||||||
|
) -> Result<PurgeReceipt> {
|
||||||
|
if !valid(workspace, 512) || !valid(installation, 512) || !valid(actor, 512) {
|
||||||
|
return fail(PluginHostErrorCode::InvalidInput);
|
||||||
|
}
|
||||||
|
validate_key(operation_key)?;
|
||||||
|
let fingerprint = digest(&("ticket-data-purge", installation, expected))?;
|
||||||
|
if let Some((saved_fingerprint,result)) = tx.query_row("SELECT fingerprint,result_json FROM plugin_data_purges WHERE workspace_id=?1 AND actor=?2 AND operation_key=?3",params![workspace,actor,operation_key],|row|Ok((row.get::<_,String>(0)?,row.get::<_,String>(1)?))).optional()? {
|
||||||
|
if saved_fingerprint!=fingerprint { return fail(PluginHostErrorCode::Conflict); }
|
||||||
|
return decode(&result);
|
||||||
|
}
|
||||||
|
atomic(tx, || {
|
||||||
|
let observed = purge_snapshot(tx, workspace, installation)?;
|
||||||
|
if observed != *expected {
|
||||||
|
return fail(PluginHostErrorCode::Conflict);
|
||||||
|
}
|
||||||
|
let holders = tx.prepare("SELECT owner,artifact_digest FROM plugin_data_holders WHERE workspace_id=?1 AND installation_id=?2")?
|
||||||
|
.query_map(params![workspace,installation],|r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?)))?.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
|
let receipt = PurgeReceipt {
|
||||||
|
purge_id: uuid::Uuid::now_v7().to_string(),
|
||||||
|
observed,
|
||||||
|
};
|
||||||
|
tx.execute("INSERT INTO plugin_data_purges(workspace_id,actor,operation_key,installation_id,fingerprint,result_json) VALUES(?1,?2,?3,?4,?5,?6)",params![workspace,actor,operation_key,installation,fingerprint,encode(&receipt)?])?;
|
||||||
|
tx.execute(
|
||||||
|
"DELETE FROM plugin_data_cells WHERE workspace_id=?1 AND installation_id=?2",
|
||||||
|
params![workspace, installation],
|
||||||
|
)?;
|
||||||
|
tx.execute(
|
||||||
|
"DELETE FROM plugin_data_cursors WHERE workspace_id=?1 AND installation_id=?2",
|
||||||
|
params![workspace, installation],
|
||||||
|
)?;
|
||||||
|
for (owner, artifact) in holders {
|
||||||
|
plugin_catalog::plugin_reference_remove(
|
||||||
|
tx,
|
||||||
|
workspace,
|
||||||
|
PluginReferenceKind::Data,
|
||||||
|
&owner,
|
||||||
|
installation,
|
||||||
|
&artifact,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
tx.execute(
|
||||||
|
"DELETE FROM plugin_data_holders WHERE workspace_id=?1 AND installation_id=?2",
|
||||||
|
params![workspace, installation],
|
||||||
|
)?;
|
||||||
|
Ok(receipt)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience constructor for the owner after resolving a wire address.
|
||||||
|
pub fn namespace(
|
||||||
|
record: &InvocationRecord,
|
||||||
|
ticket_id: &str,
|
||||||
|
address: &PluginDataAddress,
|
||||||
|
) -> Namespace {
|
||||||
|
Namespace {
|
||||||
|
workspace_id: record.workspace_id.clone(),
|
||||||
|
installation_id: record.installation_id.clone(),
|
||||||
|
ticket_id: ticket_id.into(),
|
||||||
|
collection: address.collection.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
//! Real SQLite tests of catalog artifact registration and immutable typed schema
|
||||||
|
//! namespaces. Manifest/Component validation and caller authorization are outside
|
||||||
|
//! this owner test; immutable metadata DTOs are the input boundary. No guest runs.
|
||||||
|
use super::*;
|
||||||
|
use std::{
|
||||||
|
sync::{Arc, Barrier},
|
||||||
|
time::Duration as StdDuration,
|
||||||
|
};
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
fn artifact(version: &str, schema_version: &str, schema: serde_json::Value) -> PluginArtifact {
|
||||||
|
serde_json::from_value(serde_json::json!({
|
||||||
|
"package_id":"package","name":"package","version":version,"description":null,
|
||||||
|
"package_digest":format!("sha256:{}",hash(format!("package-{version}").as_bytes())),
|
||||||
|
"artifact_digest":format!("sha256:{}",hash(format!("artifact-{version}").as_bytes())),"artifact_size":1,
|
||||||
|
"schema_version":2,"world":manifest::plugin::PLUGIN_COMPONENT_TICKET_TOOL_WORLD,
|
||||||
|
"required_imports":[],"tools":[],"connections":[],"requested_grants":[],
|
||||||
|
"data_collections":[{"collection":"collection","schema_id":"schema","schema_version":schema_version,"schema":schema}],
|
||||||
|
"config_schema":{"identity":"config","version":"1","namespace":"package","digest":format!("sha256:{}",hash(b"config")),"dcdl":""},
|
||||||
|
"revoked":false,"revocation_reason":null
|
||||||
|
})).unwrap()
|
||||||
|
}
|
||||||
|
fn schema(value_type: &str) -> serde_json::Value {
|
||||||
|
serde_json::json!({"type":"object","properties":{"value":{"type":value_type}},"required":["value"],"additionalProperties":false})
|
||||||
|
}
|
||||||
|
fn open(path: &Path) -> Connection {
|
||||||
|
let conn = Connection::open(path).unwrap();
|
||||||
|
conn.execute_batch("PRAGMA foreign_keys=ON; PRAGMA journal_mode=WAL;")
|
||||||
|
.unwrap();
|
||||||
|
conn.busy_timeout(StdDuration::from_secs(10)).unwrap();
|
||||||
|
conn
|
||||||
|
}
|
||||||
|
fn initialize(conn: &Connection) {
|
||||||
|
conn.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY); INSERT INTO workspaces VALUES('w'),('other-workspace');").unwrap();
|
||||||
|
conn.execute_batch(include_str!("../plugin_catalog_schema.sql"))
|
||||||
|
.unwrap();
|
||||||
|
for workspace in ["w", "other-workspace"] {
|
||||||
|
for installation in ["i", "other-installation"] {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO plugin_installations VALUES(?1,?2,?2,'package','op',0)",
|
||||||
|
params![workspace, installation],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn register(
|
||||||
|
conn: &mut Connection,
|
||||||
|
workspace: &str,
|
||||||
|
installation: &str,
|
||||||
|
candidate: &PluginArtifact,
|
||||||
|
) -> Result<()> {
|
||||||
|
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||||
|
add_artifact(&tx, workspace, installation, candidate)?;
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn conflict(result: Result<()>) {
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
result,
|
||||||
|
Err(Error::PluginCatalog(PluginCatalogError::Conflict))
|
||||||
|
),
|
||||||
|
"expected schema conflict, got {result:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registered_schema_content_is_immutable_per_namespace_including_revoked_artifacts() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let path = dir.path().join("catalog.db");
|
||||||
|
let mut conn = open(&path);
|
||||||
|
initialize(&conn);
|
||||||
|
let first = artifact("1.0.0", "1", schema("string"));
|
||||||
|
register(&mut conn, "w", "i", &first).unwrap();
|
||||||
|
// Different object member order is the same canonical schema, not a change.
|
||||||
|
let reordered=serde_json::from_str(r#"{"additionalProperties":false,"required":["value"],"properties":{"value":{"type":"string"}},"type":"object"}"#).unwrap();
|
||||||
|
register(&mut conn, "w", "i", &artifact("1.0.1", "1", reordered)).unwrap();
|
||||||
|
conn.execute("UPDATE plugin_artifacts SET revoked=1,revocation_reason='admin' WHERE workspace_id='w' AND installation_id='i'",[]).unwrap();
|
||||||
|
drop(conn);
|
||||||
|
let mut conn = open(&path);
|
||||||
|
let changed = artifact("2.0.0", "1", schema("integer"));
|
||||||
|
conflict(register(&mut conn, "w", "i", &changed));
|
||||||
|
assert_eq!(
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT count(*) FROM plugin_artifacts WHERE workspace_id='w' AND installation_id='i'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0)
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
// Explicit new schema version registers a separate namespace; rollback keeps
|
||||||
|
// the original schema metadata instead of reading it as the new definition.
|
||||||
|
let next = artifact("2.0.0", "2", schema("integer"));
|
||||||
|
register(&mut conn, "w", "i", &next).unwrap();
|
||||||
|
let saved = load_installation(&conn, "w", "i").unwrap();
|
||||||
|
let old = saved
|
||||||
|
.artifacts
|
||||||
|
.iter()
|
||||||
|
.find(|a| a.version == "1.0.0")
|
||||||
|
.unwrap();
|
||||||
|
let new = saved
|
||||||
|
.artifacts
|
||||||
|
.iter()
|
||||||
|
.find(|a| a.version == "2.0.0")
|
||||||
|
.unwrap();
|
||||||
|
assert!(old.revoked);
|
||||||
|
assert_eq!(old.data_collections[0].schema_version, "1");
|
||||||
|
assert_eq!(old.data_collections[0].schema, schema("string"));
|
||||||
|
assert_eq!(new.data_collections[0].schema_version, "2");
|
||||||
|
assert_eq!(new.data_collections[0].schema, schema("integer"));
|
||||||
|
// Installation and Workspace ownership are independent schema namespaces.
|
||||||
|
register(&mut conn, "w", "other-installation", &changed).unwrap();
|
||||||
|
register(&mut conn, "other-workspace", "i", &changed).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn concurrent_artifact_registration_cannot_redefine_a_schema_namespace() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let path = dir.path().join("catalog.db");
|
||||||
|
initialize(&open(&path));
|
||||||
|
let gate = Arc::new(Barrier::new(3));
|
||||||
|
let handles: Vec<_> = [("1.0.0", "string"), ("2.0.0", "integer")]
|
||||||
|
.into_iter()
|
||||||
|
.map(|(version, kind)| {
|
||||||
|
let path = path.clone();
|
||||||
|
let gate = gate.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let mut conn = open(&path);
|
||||||
|
gate.wait();
|
||||||
|
let result = register(&mut conn, "w", "i", &artifact(version, "1", schema(kind)));
|
||||||
|
match result {
|
||||||
|
Ok(()) => true,
|
||||||
|
Err(error) => {
|
||||||
|
assert!(matches!(
|
||||||
|
error,
|
||||||
|
Error::PluginCatalog(PluginCatalogError::Conflict)
|
||||||
|
));
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
gate.wait();
|
||||||
|
let winners = handles
|
||||||
|
.into_iter()
|
||||||
|
.map(|handle| usize::from(handle.join().unwrap()))
|
||||||
|
.sum::<usize>();
|
||||||
|
assert_eq!(winners, 1);
|
||||||
|
assert_eq!(
|
||||||
|
load_installation(&open(&path), "w", "i")
|
||||||
|
.unwrap()
|
||||||
|
.artifacts
|
||||||
|
.len(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- New ordered migration only. Never modify the published catalog baseline.
|
||||||
|
DROP TRIGGER plugin_reference_live;
|
||||||
|
DROP TRIGGER plugin_uninstall_referenced;
|
||||||
|
CREATE TABLE plugin_references_with_data (
|
||||||
|
workspace_id TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL CHECK(kind IN ('config','pending','active','restorable','data')),
|
||||||
|
owner TEXT NOT NULL CHECK(length(owner) BETWEEN 1 AND 512),
|
||||||
|
installation_id TEXT NOT NULL,
|
||||||
|
artifact_digest TEXT NOT NULL,
|
||||||
|
PRIMARY KEY(workspace_id, kind, owner, installation_id, artifact_digest),
|
||||||
|
FOREIGN KEY(workspace_id, installation_id, artifact_digest) REFERENCES plugin_artifacts(workspace_id, installation_id, artifact_digest) ON DELETE RESTRICT
|
||||||
|
);
|
||||||
|
INSERT INTO plugin_references_with_data SELECT * FROM plugin_references;
|
||||||
|
DROP TABLE plugin_references;
|
||||||
|
ALTER TABLE plugin_references_with_data RENAME TO plugin_references;
|
||||||
|
CREATE TRIGGER plugin_reference_live BEFORE INSERT ON plugin_references
|
||||||
|
WHEN NOT EXISTS(SELECT 1 FROM plugin_artifacts a JOIN plugin_installations i USING(workspace_id, installation_id)
|
||||||
|
WHERE a.workspace_id=NEW.workspace_id AND a.installation_id=NEW.installation_id AND a.artifact_digest=NEW.artifact_digest AND a.revoked=0 AND i.uninstalled=0)
|
||||||
|
BEGIN SELECT RAISE(ABORT,'plugin reference target unavailable'); END;
|
||||||
|
CREATE TRIGGER plugin_uninstall_referenced BEFORE UPDATE OF uninstalled ON plugin_installations
|
||||||
|
WHEN NEW.uninstalled=1 AND EXISTS(SELECT 1 FROM plugin_references r WHERE r.workspace_id=NEW.workspace_id AND r.installation_id=NEW.installation_id)
|
||||||
|
BEGIN SELECT RAISE(ABORT,'plugin installation referenced'); END;
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
-- Ticket children survive parent deletion for explicit Host retention/purge.
|
||||||
|
-- No guest SQL access; caller and triggers share the Ticket owner's DB fence.
|
||||||
|
CREATE TABLE plugin_data_cells (
|
||||||
|
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||||
|
installation_id TEXT NOT NULL,
|
||||||
|
ticket_id TEXT NOT NULL,
|
||||||
|
collection TEXT NOT NULL,
|
||||||
|
schema_id TEXT NOT NULL,
|
||||||
|
schema_version TEXT NOT NULL,
|
||||||
|
cell_key TEXT NOT NULL CHECK(length(CAST(cell_key AS BLOB)) BETWEEN 1 AND 512),
|
||||||
|
last_write TEXT NOT NULL UNIQUE,
|
||||||
|
value_json TEXT CHECK(value_json IS NULL OR (json_valid(value_json) AND length(CAST(value_json AS BLOB))<=65536)),
|
||||||
|
PRIMARY KEY(workspace_id,installation_id,ticket_id,collection,schema_id,schema_version,cell_key),
|
||||||
|
FOREIGN KEY(workspace_id,installation_id) REFERENCES plugin_installations(workspace_id,installation_id)
|
||||||
|
);
|
||||||
|
CREATE TRIGGER plugin_data_parent_insert BEFORE INSERT ON plugin_data_cells
|
||||||
|
WHEN NOT EXISTS(SELECT 1 FROM typed_tickets t JOIN workspaces w USING(workspace_id)
|
||||||
|
WHERE t.workspace_id=NEW.workspace_id AND t.ticket_id=NEW.ticket_id AND w.state='active')
|
||||||
|
BEGIN SELECT RAISE(ABORT,'Plugin data parent unavailable'); END;
|
||||||
|
CREATE TRIGGER plugin_data_parent_update BEFORE UPDATE ON plugin_data_cells
|
||||||
|
WHEN NOT EXISTS(SELECT 1 FROM typed_tickets t JOIN workspaces w USING(workspace_id)
|
||||||
|
WHERE t.workspace_id=NEW.workspace_id AND t.ticket_id=NEW.ticket_id AND w.state='active')
|
||||||
|
BEGIN SELECT RAISE(ABORT,'Plugin data parent unavailable'); END;
|
||||||
|
|
||||||
|
-- Random opaque pagination selectors only, never authorization. Expired rows
|
||||||
|
-- are discarded; API issuance is bounded per Workspace and cannot extend a
|
||||||
|
-- traversal deadline. Explicit purge invalidates installation selectors.
|
||||||
|
CREATE TABLE plugin_data_cursors (
|
||||||
|
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||||
|
installation_id TEXT NOT NULL,
|
||||||
|
cursor_token TEXT NOT NULL CHECK(length(cursor_token)=68 AND substr(cursor_token,1,4)='pdc-' AND substr(cursor_token,5) NOT GLOB '*[^0-9a-f]*'),
|
||||||
|
namespace_digest TEXT NOT NULL CHECK(length(namespace_digest)=71),
|
||||||
|
after_key TEXT NOT NULL CHECK(length(CAST(after_key AS BLOB)) BETWEEN 1 AND 256),
|
||||||
|
expires_at_ms INTEGER NOT NULL CHECK(expires_at_ms>0),
|
||||||
|
PRIMARY KEY(workspace_id,cursor_token),
|
||||||
|
UNIQUE(workspace_id,namespace_digest,after_key,expires_at_ms),
|
||||||
|
FOREIGN KEY(workspace_id,installation_id) REFERENCES plugin_installations(workspace_id,installation_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX plugin_data_cursor_expiry ON plugin_data_cursors(workspace_id,expires_at_ms);
|
||||||
|
|
||||||
|
-- Conservatively retain each writing artifact until explicit installation purge,
|
||||||
|
-- even when a later artifact overwrites its cell. owner is exact namespace hash.
|
||||||
|
CREATE TABLE plugin_data_holders (
|
||||||
|
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||||
|
installation_id TEXT NOT NULL,
|
||||||
|
owner TEXT NOT NULL,
|
||||||
|
artifact_digest TEXT NOT NULL,
|
||||||
|
PRIMARY KEY(workspace_id,installation_id,owner,artifact_digest),
|
||||||
|
FOREIGN KEY(workspace_id,installation_id,artifact_digest) REFERENCES plugin_artifacts(workspace_id,installation_id,artifact_digest)
|
||||||
|
);
|
||||||
|
CREATE TABLE plugin_data_receipts (
|
||||||
|
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||||
|
invocation_id TEXT NOT NULL,
|
||||||
|
operation_key TEXT NOT NULL CHECK(length(CAST(operation_key AS BLOB)) BETWEEN 1 AND 256),
|
||||||
|
namespace_json TEXT NOT NULL CHECK(json_valid(namespace_json)),
|
||||||
|
fingerprint TEXT NOT NULL CHECK(length(fingerprint)=71),
|
||||||
|
mutation_id TEXT NOT NULL UNIQUE,
|
||||||
|
result_digest TEXT NOT NULL CHECK(length(result_digest)=71),
|
||||||
|
result_json TEXT CHECK(result_json IS NULL OR (json_valid(result_json) AND length(CAST(result_json AS BLOB))<=262144)),
|
||||||
|
PRIMARY KEY(workspace_id,invocation_id,operation_key),
|
||||||
|
FOREIGN KEY(workspace_id,invocation_id) REFERENCES plugin_invocations(workspace_id,invocation_id)
|
||||||
|
);
|
||||||
|
CREATE TRIGGER plugin_data_receipt_immutable BEFORE UPDATE ON plugin_data_receipts
|
||||||
|
WHEN NOT(OLD.result_json IS NOT NULL AND NEW.result_json IS NULL
|
||||||
|
AND NEW.workspace_id=OLD.workspace_id AND NEW.invocation_id=OLD.invocation_id
|
||||||
|
AND NEW.operation_key=OLD.operation_key AND NEW.namespace_json=OLD.namespace_json
|
||||||
|
AND NEW.fingerprint=OLD.fingerprint AND NEW.mutation_id=OLD.mutation_id AND NEW.result_digest=OLD.result_digest
|
||||||
|
AND EXISTS(SELECT 1 FROM plugin_invocations i WHERE i.workspace_id=OLD.workspace_id AND i.invocation_id=OLD.invocation_id AND i.expired=1))
|
||||||
|
BEGIN SELECT RAISE(ABORT,'Plugin data receipt immutable'); END;
|
||||||
|
CREATE TRIGGER plugin_data_receipt_no_replace BEFORE INSERT ON plugin_data_receipts
|
||||||
|
WHEN EXISTS(SELECT 1 FROM plugin_data_receipts WHERE (workspace_id=NEW.workspace_id AND invocation_id=NEW.invocation_id AND operation_key=NEW.operation_key) OR mutation_id=NEW.mutation_id)
|
||||||
|
BEGIN SELECT RAISE(ABORT,'Plugin data operation key retained'); END;
|
||||||
|
CREATE TRIGGER plugin_data_receipt_retained BEFORE DELETE ON plugin_data_receipts
|
||||||
|
WHEN EXISTS(SELECT 1 FROM workspaces WHERE workspace_id=OLD.workspace_id)
|
||||||
|
BEGIN SELECT RAISE(ABORT,'Plugin data receipt retained'); END;
|
||||||
|
CREATE TRIGGER plugin_data_receipt_expiry AFTER UPDATE OF expired ON plugin_invocations
|
||||||
|
WHEN NEW.expired=1 AND OLD.expired=0
|
||||||
|
BEGIN UPDATE plugin_data_receipts SET result_json=NULL WHERE workspace_id=NEW.workspace_id AND invocation_id=NEW.invocation_id AND result_json IS NOT NULL; END;
|
||||||
|
|
||||||
|
CREATE TRIGGER plugin_effect_data_collision BEFORE INSERT ON plugin_effect_receipts
|
||||||
|
WHEN EXISTS(SELECT 1 FROM plugin_data_receipts WHERE workspace_id=NEW.workspace_id AND invocation_id=NEW.invocation_id AND operation_key=NEW.effect_key)
|
||||||
|
BEGIN SELECT RAISE(ABORT,'Plugin operation key belongs to data'); END;
|
||||||
|
CREATE TRIGGER plugin_data_effect_collision BEFORE INSERT ON plugin_data_receipts
|
||||||
|
WHEN EXISTS(SELECT 1 FROM plugin_effect_receipts WHERE workspace_id=NEW.workspace_id AND invocation_id=NEW.invocation_id AND effect_key=NEW.operation_key)
|
||||||
|
BEGIN SELECT RAISE(ABORT,'Plugin operation key belongs to HTTP'); END;
|
||||||
|
|
||||||
|
-- Admin operations are not guest invocations; this is only an atomic purge
|
||||||
|
-- receipt, never an execution identity or saved authorization decision.
|
||||||
|
CREATE TABLE plugin_data_purges (
|
||||||
|
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||||
|
actor TEXT NOT NULL,
|
||||||
|
operation_key TEXT NOT NULL CHECK(length(CAST(operation_key AS BLOB)) BETWEEN 1 AND 256),
|
||||||
|
installation_id TEXT NOT NULL,
|
||||||
|
fingerprint TEXT NOT NULL,
|
||||||
|
result_json TEXT NOT NULL CHECK(json_valid(result_json)),
|
||||||
|
PRIMARY KEY(workspace_id,actor,operation_key)
|
||||||
|
);
|
||||||
|
CREATE TRIGGER plugin_data_purge_immutable BEFORE UPDATE ON plugin_data_purges
|
||||||
|
BEGIN SELECT RAISE(ABORT,'Plugin purge receipt immutable'); END;
|
||||||
|
CREATE TRIGGER plugin_data_purge_no_replace BEFORE INSERT ON plugin_data_purges
|
||||||
|
WHEN EXISTS(SELECT 1 FROM plugin_data_purges WHERE workspace_id=NEW.workspace_id AND actor=NEW.actor AND operation_key=NEW.operation_key)
|
||||||
|
BEGIN SELECT RAISE(ABORT,'Plugin purge key retained'); END;
|
||||||
|
CREATE TRIGGER plugin_data_purge_retained BEFORE DELETE ON plugin_data_purges
|
||||||
|
WHEN EXISTS(SELECT 1 FROM workspaces WHERE workspace_id=OLD.workspace_id)
|
||||||
|
BEGIN SELECT RAISE(ABORT,'Plugin purge receipt retained'); END;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
|||||||
|
// The real retained predecessor exercises both the catalog CHECK rebuild and
|
||||||
|
// new tables in one owner transaction; no schema-number relabeling is used.
|
||||||
|
#[test]
|
||||||
|
fn plugin_data_migration_preserves_references_and_matches_fresh_schema() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("server.db");
|
||||||
|
prepare_retained_schema(&path, 110);
|
||||||
|
let conn = Connection::open(&path).unwrap();
|
||||||
|
configure_sqlite(&conn).unwrap();
|
||||||
|
seed_migration_workspace(&conn);
|
||||||
|
let digest = format!("sha256:{}", "a".repeat(64));
|
||||||
|
conn.execute("INSERT INTO plugin_installations(workspace_id,installation_id,alias,package_id,last_operation) VALUES('space','installation','alias','package','install')", []).unwrap();
|
||||||
|
conn.execute("INSERT INTO plugin_artifacts(workspace_id,installation_id,artifact_digest,package_digest,version,artifact_size,metadata_json) VALUES('space','installation',?1,?1,'1',1,'{}')", [&digest]).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO plugin_references VALUES('space','config','config-owner','installation',?1)",
|
||||||
|
[&digest],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let history = workspace_schema_migration_history(&conn).unwrap();
|
||||||
|
drop(conn);
|
||||||
|
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||||
|
store.with_conn(|conn| {
|
||||||
|
assert_eq!(conn.query_row("SELECT count(*) FROM plugin_references WHERE kind='config' AND owner='config-owner'", [], |r| r.get::<_, i64>(0))?, 1);
|
||||||
|
conn.execute("INSERT INTO plugin_references VALUES('space','data','data-owner','installation',?1)", [&digest])?;
|
||||||
|
assert!(conn.execute("UPDATE plugin_installations SET uninstalled=1 WHERE workspace_id='space'", []).is_err());
|
||||||
|
assert!(conn.execute("INSERT INTO plugin_references VALUES('space','unknown','bad-owner','installation',?1)", [&digest]).is_err());
|
||||||
|
assert_eq!(&workspace_schema_migration_history(conn)?[..history.len()], history.as_slice());
|
||||||
|
let fresh = SqliteWorkspaceStore::in_memory_for_test()?;
|
||||||
|
let objects = |connection: &Connection| -> Result<Vec<(String,String)>> {
|
||||||
|
Ok(connection.prepare("SELECT name,sql FROM sqlite_schema WHERE sql IS NOT NULL AND (name LIKE 'plugin_data_%' OR name='plugin_references') ORDER BY name")?.query_map([], |r| Ok((r.get(0)?,r.get(1)?)))?.collect::<rusqlite::Result<Vec<_>>>()?)
|
||||||
|
};
|
||||||
|
let expected = fresh.with_conn(objects)?;
|
||||||
|
assert_eq!(objects(conn)?, expected);
|
||||||
|
assert_eq!(conn.query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |r| r.get::<_,i64>(0))?, 0);
|
||||||
|
Ok(())
|
||||||
|
}).unwrap();
|
||||||
|
drop(store);
|
||||||
|
SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plugin_data_migration_late_failure_rolls_back_catalog_rebuild_and_history() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("server.db");
|
||||||
|
prepare_retained_schema(&path, 110);
|
||||||
|
let conn = Connection::open(&path).unwrap();
|
||||||
|
configure_sqlite(&conn).unwrap();
|
||||||
|
conn.execute_batch("CREATE TABLE plugin_data_receipts(unrelated INTEGER)")
|
||||||
|
.unwrap();
|
||||||
|
let history = workspace_schema_migration_history(&conn).unwrap();
|
||||||
|
let schema = |conn: &Connection| {
|
||||||
|
conn.query_row("SELECT group_concat(sql,char(10)) FROM (SELECT sql FROM sqlite_schema WHERE sql IS NOT NULL ORDER BY name)", [], |r| r.get::<_,String>(0)).unwrap()
|
||||||
|
};
|
||||||
|
let before = schema(&conn);
|
||||||
|
assert!(migrate_plugin_data_v110_to_v111(&conn).is_err());
|
||||||
|
assert_eq!(schema(&conn), before);
|
||||||
|
assert_eq!(workspace_schema_migration_history(&conn).unwrap(), history);
|
||||||
|
conn.execute_batch("DROP TABLE plugin_data_receipts")
|
||||||
|
.unwrap();
|
||||||
|
migrate_plugin_data_v110_to_v111(&conn).unwrap();
|
||||||
|
assert_eq!(current_schema_version(&conn).unwrap(), 111);
|
||||||
|
assert!(migrate_plugin_data_v110_to_v111(&conn).is_err());
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ pub struct AdmittedPluginOperation {
|
|||||||
pub plan: StoredPluginPlan,
|
pub plan: StoredPluginPlan,
|
||||||
pub pin: WorkerPluginPin,
|
pub pin: WorkerPluginPin,
|
||||||
pub binding: Option<PluginConnectionBinding>,
|
pub binding: Option<PluginConnectionBinding>,
|
||||||
|
pub data_collections: Vec<PluginDataSchemaMetadata>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `execution` is derived by the authenticated Host boundary from current Worker
|
/// `execution` is derived by the authenticated Host boundary from current Worker
|
||||||
@@ -131,7 +132,12 @@ pub fn authorize(
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
Ok(AdmittedPluginOperation { plan, pin, binding })
|
Ok(AdmittedPluginOperation {
|
||||||
|
plan,
|
||||||
|
pin,
|
||||||
|
binding,
|
||||||
|
data_collections: artifact.data_collections,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn live_binding(
|
pub(crate) fn live_binding(
|
||||||
|
|||||||
@@ -12,17 +12,17 @@ fn plugin_receipt_tail_migrates_true_predecessor_reopens_and_matches_fresh_schem
|
|||||||
assert!(!table_exists(&conn, "plugin_invocations").unwrap());
|
assert!(!table_exists(&conn, "plugin_invocations").unwrap());
|
||||||
let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap();
|
let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap();
|
||||||
assert_eq!(plan.current_schema_version, 108);
|
assert_eq!(plan.current_schema_version, 108);
|
||||||
assert_eq!(plan.migrations.iter().map(|step| step.version).collect::<Vec<_>>(), [109, 110]);
|
assert_eq!(plan.migrations.iter().map(|step| step.version).collect::<Vec<_>>(), [109, 110, 111]);
|
||||||
assert_eq!(current_schema_version(&conn).unwrap(), 108);
|
assert_eq!(current_schema_version(&conn).unwrap(), 108);
|
||||||
drop(conn);
|
drop(conn);
|
||||||
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||||
store.with_conn(|conn| {
|
store.with_conn(|conn| {
|
||||||
crate::plugin_host::verify_receipt_schema(conn)?;
|
crate::plugin_host::verify_receipt_schema(conn)?;
|
||||||
assert_eq!(current_schema_version(conn)?, 110);
|
assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION);
|
||||||
let upgraded = workspace_schema_migration_history(conn)?;
|
let upgraded = workspace_schema_migration_history(conn)?;
|
||||||
assert_eq!(&upgraded[..history.len()], history.as_slice());
|
assert_eq!(&upgraded[..history.len()], history.as_slice());
|
||||||
assert_eq!(upgraded.iter().find(|(version, _)| *version == 109).unwrap().1, "Durable Plugin Host invocations and HTTPS effect receipts");
|
assert_eq!(upgraded.iter().find(|(version, _)| *version == 109).unwrap().1, "Durable Plugin Host invocations and HTTPS effect receipts");
|
||||||
assert_eq!(upgraded.last().unwrap().1, "Worker Plugin launch selection associations");
|
assert_eq!(upgraded.iter().find(|(version, _)| *version == 110).unwrap().1, "Worker Plugin launch selection associations");
|
||||||
assert_eq!(conn.query_row("SELECT count(*) FROM workspaces WHERE workspace_id='space'", [], |row| row.get::<_, i64>(0))?, 1);
|
assert_eq!(conn.query_row("SELECT count(*) FROM workspaces WHERE workspace_id='space'", [], |row| row.get::<_, i64>(0))?, 1);
|
||||||
assert_eq!(conn.query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |row| row.get::<_, i64>(0))?, 0);
|
assert_eq!(conn.query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |row| row.get::<_, i64>(0))?, 0);
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ fn plugin_plan_migration_marker_failure_preserves_catalog_then_retries_and_match
|
|||||||
"Durable Plugin Host invocations and HTTPS effect receipts".into(),
|
"Durable Plugin Host invocations and HTTPS effect receipts".into(),
|
||||||
));
|
));
|
||||||
expected.push((110, "Worker Plugin launch selection associations".into()));
|
expected.push((110, "Worker Plugin launch selection associations".into()));
|
||||||
|
expected.push((
|
||||||
|
111,
|
||||||
|
"Typed Plugin Ticket extension data and atomic mutation receipts".into(),
|
||||||
|
));
|
||||||
assert_eq!(workspace_schema_migration_history(conn)?, expected);
|
assert_eq!(workspace_schema_migration_history(conn)?, expected);
|
||||||
subject_assignment_migration::verify(conn)?;
|
subject_assignment_migration::verify(conn)?;
|
||||||
crate::plugin_plan::verify_plan_schema(conn)?;
|
crate::plugin_plan::verify_plan_schema(conn)?;
|
||||||
|
|||||||
@@ -227,7 +227,10 @@ pub fn lookup(conn: &Connection, workspace: &str, invocation_id: &str) -> Result
|
|||||||
|
|
||||||
// Never trust a passed record's mutable flags/deadline or accept a fabricated
|
// Never trust a passed record's mutable flags/deadline or accept a fabricated
|
||||||
// plan/execution/consumer. Records may be stale only in monotonic terminal flags.
|
// plan/execution/consumer. Records may be stale only in monotonic terminal flags.
|
||||||
fn verify_record(conn: &Connection, supplied: &InvocationRecord) -> Result<InvocationRecord> {
|
pub(crate) fn verify_record(
|
||||||
|
conn: &Connection,
|
||||||
|
supplied: &InvocationRecord,
|
||||||
|
) -> Result<InvocationRecord> {
|
||||||
let saved = lookup(conn, &supplied.workspace_id, &supplied.invocation_id)?;
|
let saved = lookup(conn, &supplied.workspace_id, &supplied.invocation_id)?;
|
||||||
if supplied.plan_id != saved.plan_id
|
if supplied.plan_id != saved.plan_id
|
||||||
|| supplied.installation_id != saved.installation_id
|
|| supplied.installation_id != saved.installation_id
|
||||||
@@ -334,7 +337,11 @@ pub fn effect_admit(
|
|||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO plugin_effect_receipts(workspace_id,invocation_id,effect_key,fingerprint,admitted_at_ms,state,error_json) VALUES(?1,?2,?3,?4,?5,'pending',?6)",
|
"INSERT INTO plugin_effect_receipts(workspace_id,invocation_id,effect_key,fingerprint,admitted_at_ms,state,error_json) VALUES(?1,?2,?3,?4,?5,'pending',?6)",
|
||||||
params![record.workspace_id, record.invocation_id, effect_key, fingerprint, now_ms, encode(&unknown)?],
|
params![record.workspace_id, record.invocation_id, effect_key, fingerprint, now_ms, encode(&unknown)?],
|
||||||
)?;
|
).map_err(|error| match &error {
|
||||||
|
rusqlite::Error::SqliteFailure(_, Some(message)) if message == "Plugin operation key belongs to data" =>
|
||||||
|
Error::from(host_error(PluginHostErrorCode::Conflict, PluginEffectObservation::Indeterminate)),
|
||||||
|
_ => Error::from(error),
|
||||||
|
})?;
|
||||||
Ok(EffectAdmission::Dispatch)
|
Ok(EffectAdmission::Dispatch)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,8 +448,9 @@ pub fn cancel(tx: &Transaction<'_>, workspace: &str, invocation_id: &str) -> Res
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Expire matching invocations and erase response/error payloads, never identity,
|
/// Expire matching invocations and erase response/error payloads, never identity,
|
||||||
/// fingerprints or keys. Returns the number of receipts newly tombstoned. Even a
|
/// fingerprints or keys. Returns the number of HTTPS receipts newly tombstoned;
|
||||||
/// subsequent backwards clock cannot resurrect an explicitly expired invocation.
|
/// extension-data payload expiry follows the same invocation flag atomically.
|
||||||
|
/// A backwards clock cannot resurrect an explicitly expired invocation.
|
||||||
/// Tombstones survive until the parent's Workspace deletion cascade.
|
/// Tombstones survive until the parent's Workspace deletion cascade.
|
||||||
pub fn prune(tx: &Transaction<'_>, workspace: &str, now_ms: i64) -> Result<usize> {
|
pub fn prune(tx: &Transaction<'_>, workspace: &str, now_ms: i64) -> Result<usize> {
|
||||||
if !valid_identity(workspace, 512) || now_ms < 0 {
|
if !valid_identity(workspace, 512) || now_ms < 0 {
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ fn make_plan(
|
|||||||
}],
|
}],
|
||||||
connections: vec![],
|
connections: vec![],
|
||||||
requested_grants: vec![],
|
requested_grants: vec![],
|
||||||
|
data_collections: vec![],
|
||||||
config_schema: PluginSchemaContribution {
|
config_schema: PluginSchemaContribution {
|
||||||
identity: "schema".into(),
|
identity: "schema".into(),
|
||||||
version: "1".into(),
|
version: "1".into(),
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ pub struct PluginEnablement {
|
|||||||
pub artifact_digest: String,
|
pub artifact_digest: String,
|
||||||
pub tools: Vec<String>,
|
pub tools: Vec<String>,
|
||||||
pub logging: bool,
|
pub logging: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub ticket_read: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub ticket_data_read: Vec<manifest::PluginTicketDataGrant>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub ticket_data_write: Vec<manifest::PluginTicketDataGrant>,
|
||||||
pub connections: BTreeMap<String, String>,
|
pub connections: BTreeMap<String, String>,
|
||||||
pub config: serde_json::Value,
|
pub config: serde_json::Value,
|
||||||
}
|
}
|
||||||
@@ -164,7 +170,7 @@ pub(crate) fn evaluate_catalog_config(
|
|||||||
// Each alias has its own closed schema. Reusing an artifact with distinct
|
// Each alias has its own closed schema. Reusing an artifact with distinct
|
||||||
// bindings is intentional and does not manufacture another schema authority.
|
// bindings is intentional and does not manufacture another schema authority.
|
||||||
let enabled_fields = artifacts.iter().map(|(alias, artifact)| {
|
let enabled_fields = artifacts.iter().map(|(alias, artifact)| {
|
||||||
format!("{} = {{ installation_id = String; expected_last_operation = String; package_digest = String; artifact_digest = String; tools = [...String]; logging = Bool default false; connections = {{ ...String }} default {{}}; config = ({}); }};", alias, artifact.config_schema.dcdl)
|
format!("{} = {{ installation_id = String; expected_last_operation = String; package_digest = String; artifact_digest = String; tools = [...String]; logging = Bool default false; ticket_read = Bool default false; ticket_data_read = [...{{ collection = String; schema_id = String; schema_version = String; }}] default []; ticket_data_write = [...{{ collection = String; schema_id = String; schema_version = String; }}] default []; connections = {{ ...String }} default {{}}; config = ({}); }};", alias, artifact.config_schema.dcdl)
|
||||||
}).collect::<Vec<_>>().join("\n");
|
}).collect::<Vec<_>>().join("\n");
|
||||||
let enabled_schema = if enabled_fields.is_empty() {
|
let enabled_schema = if enabled_fields.is_empty() {
|
||||||
"{} default {}".to_string()
|
"{} default {}".to_string()
|
||||||
@@ -238,6 +244,7 @@ fn validate_enablement(
|
|||||||
| manifest::plugin::PLUGIN_COMPONENT_HTTPS_TOOL_WORLD
|
| manifest::plugin::PLUGIN_COMPONENT_HTTPS_TOOL_WORLD
|
||||||
| manifest::plugin::PLUGIN_COMPONENT_LOGGED_TOOL_WORLD
|
| manifest::plugin::PLUGIN_COMPONENT_LOGGED_TOOL_WORLD
|
||||||
| manifest::plugin::PLUGIN_COMPONENT_HTTPS_LOGGED_TOOL_WORLD
|
| manifest::plugin::PLUGIN_COMPONENT_HTTPS_LOGGED_TOOL_WORLD
|
||||||
|
| "yoi:plugin/ticket-tool-plugin@2.1.0"
|
||||||
)
|
)
|
||||||
|| artifact.required_imports.iter().any(|i| {
|
|| artifact.required_imports.iter().any(|i| {
|
||||||
!matches!(
|
!matches!(
|
||||||
@@ -246,6 +253,9 @@ fn validate_enablement(
|
|||||||
| "yoi:host/connections@2.0.0"
|
| "yoi:host/connections@2.0.0"
|
||||||
| "yoi:host/http@2.0.0"
|
| "yoi:host/http@2.0.0"
|
||||||
| "yoi:host/logging@2.0.0"
|
| "yoi:host/logging@2.0.0"
|
||||||
|
| "yoi:host/objects@2.1.0"
|
||||||
|
| "yoi:host/ticket-read@2.1.0"
|
||||||
|
| "yoi:host/extension-data@2.1.0"
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
@@ -266,6 +276,39 @@ fn validate_enablement(
|
|||||||
{
|
{
|
||||||
return Err(config_error("logging exceeds package request"));
|
return Err(config_error("logging exceeds package request"));
|
||||||
}
|
}
|
||||||
|
if enabled.ticket_read
|
||||||
|
&& !artifact
|
||||||
|
.requested_grants
|
||||||
|
.contains(&PluginGrantMetadata::TicketRead)
|
||||||
|
{
|
||||||
|
return Err(config_error("Ticket read exceeds package request"));
|
||||||
|
}
|
||||||
|
for (write, grants) in [
|
||||||
|
(false, &enabled.ticket_data_read),
|
||||||
|
(true, &enabled.ticket_data_write),
|
||||||
|
] {
|
||||||
|
if grants.iter().collect::<BTreeSet<_>>().len() != grants.len() {
|
||||||
|
return Err(config_error("duplicate Ticket data grant"));
|
||||||
|
}
|
||||||
|
for grant in grants {
|
||||||
|
let metadata = if write {
|
||||||
|
PluginGrantMetadata::TicketDataWrite {
|
||||||
|
collection: grant.collection.clone(),
|
||||||
|
schema_id: grant.schema_id.clone(),
|
||||||
|
schema_version: grant.schema_version.clone(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
PluginGrantMetadata::TicketDataRead {
|
||||||
|
collection: grant.collection.clone(),
|
||||||
|
schema_id: grant.schema_id.clone(),
|
||||||
|
schema_version: grant.schema_version.clone(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !artifact.requested_grants.contains(&metadata) {
|
||||||
|
return Err(config_error("Ticket data grant exceeds package request"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
for (slot, binding) in &enabled.connections {
|
for (slot, binding) in &enabled.connections {
|
||||||
let requested = artifact
|
let requested = artifact
|
||||||
.connections
|
.connections
|
||||||
@@ -390,6 +433,15 @@ fn project_profile_plugins(
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|tool| !enabled.tools.contains(tool))
|
.any(|tool| !enabled.tools.contains(tool))
|
||||||
|| (selection.logging && !enabled.logging)
|
|| (selection.logging && !enabled.logging)
|
||||||
|
|| (selection.ticket_read && !enabled.ticket_read)
|
||||||
|
|| selection
|
||||||
|
.ticket_data_read
|
||||||
|
.iter()
|
||||||
|
.any(|grant| !enabled.ticket_data_read.contains(grant))
|
||||||
|
|| selection
|
||||||
|
.ticket_data_write
|
||||||
|
.iter()
|
||||||
|
.any(|grant| !enabled.ticket_data_write.contains(grant))
|
||||||
|| selection
|
|| selection
|
||||||
.https_slots
|
.https_slots
|
||||||
.iter()
|
.iter()
|
||||||
@@ -412,6 +464,23 @@ fn project_profile_plugins(
|
|||||||
if selection.logging {
|
if selection.logging {
|
||||||
grants.push(PluginGrant::Logging);
|
grants.push(PluginGrant::Logging);
|
||||||
}
|
}
|
||||||
|
if selection.ticket_read {
|
||||||
|
grants.push(PluginGrant::TicketRead);
|
||||||
|
}
|
||||||
|
for grant in &selection.ticket_data_read {
|
||||||
|
grants.push(PluginGrant::TicketDataRead {
|
||||||
|
collection: grant.collection.clone(),
|
||||||
|
schema_id: grant.schema_id.clone(),
|
||||||
|
schema_version: grant.schema_version.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for grant in &selection.ticket_data_write {
|
||||||
|
grants.push(PluginGrant::TicketDataWrite {
|
||||||
|
collection: grant.collection.clone(),
|
||||||
|
schema_id: grant.schema_id.clone(),
|
||||||
|
schema_version: grant.schema_version.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
let mut bindings = Vec::new();
|
let mut bindings = Vec::new();
|
||||||
for slot in &selection.https_slots {
|
for slot in &selection.https_slots {
|
||||||
let binding_id = &enabled.connections[slot];
|
let binding_id = &enabled.connections[slot];
|
||||||
|
|||||||
@@ -120,6 +120,10 @@ mod schema_convergence {
|
|||||||
version: 110,
|
version: 110,
|
||||||
name: "Worker Plugin launch selection associations".into(),
|
name: "Worker Plugin launch selection associations".into(),
|
||||||
},
|
},
|
||||||
|
WorkspaceSchemaMigrationStep {
|
||||||
|
version: 111,
|
||||||
|
name: "Typed Plugin Ticket extension data and atomic mutation receipts".into(),
|
||||||
|
},
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -141,6 +145,7 @@ mod schema_convergence {
|
|||||||
expected.push((108, "Subject Ticket responsibility and current-body notification routing".into()));
|
expected.push((108, "Subject Ticket responsibility and current-body notification routing".into()));
|
||||||
expected.push((109, "Durable Plugin Host invocations and HTTPS effect receipts".into()));
|
expected.push((109, "Durable Plugin Host invocations and HTTPS effect receipts".into()));
|
||||||
expected.push((110, "Worker Plugin launch selection associations".into()));
|
expected.push((110, "Worker Plugin launch selection associations".into()));
|
||||||
|
expected.push((111, "Typed Plugin Ticket extension data and atomic mutation receipts".into()));
|
||||||
assert_eq!(workspace_schema_migration_history(conn)?, expected);
|
assert_eq!(workspace_schema_migration_history(conn)?, expected);
|
||||||
for table in ["passkey_credentials", "passkey_metadata", "passkey_addition_receipts", "backend_jobs", "backend_job_attempts"] {
|
for table in ["passkey_credentials", "passkey_metadata", "passkey_addition_receipts", "backend_jobs", "backend_job_attempts"] {
|
||||||
assert_eq!(state_contract_schema_signature(conn, table), fresh.with_conn(|c| Ok(state_contract_schema_signature(c, table)))?, "{table}");
|
assert_eq!(state_contract_schema_signature(conn, table), fresh.with_conn(|c| Ok(state_contract_schema_signature(c, table)))?, "{table}");
|
||||||
|
|||||||
@@ -13806,6 +13806,33 @@ impl server_api::ServerApi for ServerApiContractService {
|
|||||||
plugin::uninstall(api, actor, workspace_id, installation_id, request).await
|
plugin::uninstall(api, actor, workspace_id, installation_id, request).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn plugin_data_observe(
|
||||||
|
&self,
|
||||||
|
actor: RequestActor,
|
||||||
|
workspace_id: String,
|
||||||
|
installation_id: String,
|
||||||
|
) -> std::result::Result<server_api::PluginDataPurgeState, server_api::RepositoryApiError> {
|
||||||
|
plugin::data_observe(self.workspace_api()?, actor, workspace_id, installation_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn plugin_data_purge(
|
||||||
|
&self,
|
||||||
|
actor: RequestActor,
|
||||||
|
workspace_id: String,
|
||||||
|
installation_id: String,
|
||||||
|
request: server_api::PluginDataPurgeRequest,
|
||||||
|
) -> std::result::Result<server_api::PluginDataPurgeResponse, server_api::RepositoryApiError>
|
||||||
|
{
|
||||||
|
plugin::data_purge(
|
||||||
|
self.workspace_api()?,
|
||||||
|
actor,
|
||||||
|
workspace_id,
|
||||||
|
installation_id,
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
async fn plugin_operation(
|
async fn plugin_operation(
|
||||||
&self,
|
&self,
|
||||||
actor: RequestActor,
|
actor: RequestActor,
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ pub(super) fn router(service: Arc<ServerApiContractService>) -> Router {
|
|||||||
.merge(server_api_axum::plugin_artifact_add(service.clone()))
|
.merge(server_api_axum::plugin_artifact_add(service.clone()))
|
||||||
.merge(server_api_axum::plugin_revoke(service.clone()))
|
.merge(server_api_axum::plugin_revoke(service.clone()))
|
||||||
.merge(server_api_axum::plugin_uninstall(service.clone()))
|
.merge(server_api_axum::plugin_uninstall(service.clone()))
|
||||||
|
.merge(server_api_axum::plugin_data_observe(service.clone()))
|
||||||
|
.merge(server_api_axum::plugin_data_purge(service.clone()))
|
||||||
.merge(server_api_axum::plugin_operation(service))
|
.merge(server_api_axum::plugin_operation(service))
|
||||||
.layer(middleware::from_fn(normalize_rejections))
|
.layer(middleware::from_fn(normalize_rejections))
|
||||||
}
|
}
|
||||||
@@ -225,3 +227,85 @@ pub(super) async fn operation(
|
|||||||
.await
|
.await
|
||||||
.map_err(failure)
|
.map_err(failure)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn data_failure(error: Error) -> RepositoryApiError {
|
||||||
|
if let Error::PluginHost(error) = error {
|
||||||
|
let (status, code) = match error.code {
|
||||||
|
PluginHostErrorCode::NotFound => (404, "not_found"),
|
||||||
|
PluginHostErrorCode::Denied => (403, "access_denied"),
|
||||||
|
PluginHostErrorCode::Conflict => (409, "plugin_data_conflict"),
|
||||||
|
PluginHostErrorCode::InvalidInput => (400, "plugin_invalid"),
|
||||||
|
_ => (503, "plugin_data_outcome_unknown"),
|
||||||
|
};
|
||||||
|
RepositoryApiError::new(status, code, "Plugin data operation", vec![])
|
||||||
|
} else {
|
||||||
|
failure(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn purge_state(state: crate::plugin_data::PurgeSnapshot) -> PluginDataPurgeState {
|
||||||
|
PluginDataPurgeState {
|
||||||
|
installation_last_operation: state.installation_last_operation,
|
||||||
|
cells_digest: state.cells_digest,
|
||||||
|
cell_count: state.cell_count,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn data_observe(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
actor: RequestActor,
|
||||||
|
workspace: String,
|
||||||
|
installation: String,
|
||||||
|
) -> CatalogResult<PluginDataPurgeState> {
|
||||||
|
// Observing a purge confirmation is an administrator operation. It is only
|
||||||
|
// available after all execution artifacts have been irrevocably revoked.
|
||||||
|
authorize(api, &workspace, &actor, true).await?;
|
||||||
|
let store = api.config_store.clone();
|
||||||
|
store
|
||||||
|
.db_executor()
|
||||||
|
.run(move || {
|
||||||
|
store.with_immediate_transaction(|tx| {
|
||||||
|
crate::plugin_data::purge_snapshot(tx, &workspace, &installation)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map(purge_state)
|
||||||
|
.map_err(data_failure)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn data_purge(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
actor: RequestActor,
|
||||||
|
workspace: String,
|
||||||
|
installation: String,
|
||||||
|
request: PluginDataPurgeRequest,
|
||||||
|
) -> CatalogResult<PluginDataPurgeResponse> {
|
||||||
|
// Authenticate again even when this will be a receipt replay. Management
|
||||||
|
// credentials are never borrowed by guest invocations or used to run them.
|
||||||
|
authorize(api, &workspace, &actor, true).await?;
|
||||||
|
let actor = actor_identity(&actor);
|
||||||
|
let store = api.config_store.clone();
|
||||||
|
store
|
||||||
|
.db_executor()
|
||||||
|
.run(move || {
|
||||||
|
store.with_immediate_transaction(|tx| {
|
||||||
|
crate::plugin_data::purge(
|
||||||
|
tx,
|
||||||
|
&workspace,
|
||||||
|
&installation,
|
||||||
|
&actor,
|
||||||
|
&request.operation_key,
|
||||||
|
&crate::plugin_data::PurgeSnapshot {
|
||||||
|
installation_last_operation: request.expected.installation_last_operation,
|
||||||
|
cells_digest: request.expected.cells_digest,
|
||||||
|
cell_count: request.expected.cell_count,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map(|receipt| PluginDataPurgeResponse {
|
||||||
|
purge_id: receipt.purge_id,
|
||||||
|
observed: purge_state(receipt.observed),
|
||||||
|
})
|
||||||
|
.map_err(data_failure)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,331 @@
|
|||||||
|
//! Narrow Ticket owner adapter. Resource selectors never replace current
|
||||||
|
//! Subject/delegation authorization; values are projected explicitly and never
|
||||||
|
//! serialized from the general Ticket detail (which contains thread/admin data).
|
||||||
|
use super::*;
|
||||||
|
use crate::plugin_catalog::{PluginReference, PluginReferenceKind};
|
||||||
|
use crate::plugin_data as store;
|
||||||
|
use server_api::authorization::{OperationAuthorizationRequest, PermissionResource};
|
||||||
|
|
||||||
|
fn not_found() -> PluginHostError {
|
||||||
|
policy::map_error(policy::failure(PluginHostErrorCode::NotFound))
|
||||||
|
}
|
||||||
|
fn invalid() -> PluginHostError {
|
||||||
|
policy::map_error(policy::failure(PluginHostErrorCode::InvalidInput))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collection_granted(
|
||||||
|
admitted: &policy::AdmittedPluginOperation,
|
||||||
|
collection: &PluginDataCollection,
|
||||||
|
write: bool,
|
||||||
|
) -> bool {
|
||||||
|
admitted
|
||||||
|
.pin
|
||||||
|
.projection()
|
||||||
|
.grants
|
||||||
|
.iter()
|
||||||
|
.any(|grant| match grant {
|
||||||
|
crate::plugin_plan::PluginGrant::TicketDataRead {
|
||||||
|
collection: c,
|
||||||
|
schema_id,
|
||||||
|
schema_version,
|
||||||
|
} if !write => {
|
||||||
|
c == &collection.collection
|
||||||
|
&& schema_id == &collection.schema_id
|
||||||
|
&& schema_version == &collection.schema_version
|
||||||
|
}
|
||||||
|
crate::plugin_plan::PluginGrant::TicketDataWrite {
|
||||||
|
collection: c,
|
||||||
|
schema_id,
|
||||||
|
schema_version,
|
||||||
|
} if write => {
|
||||||
|
c == &collection.collection
|
||||||
|
&& schema_id == &collection.schema_id
|
||||||
|
&& schema_version == &collection.schema_version
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn access_granted(admitted: &policy::AdmittedPluginOperation, access: PluginTicketAccess) -> bool {
|
||||||
|
admitted
|
||||||
|
.pin
|
||||||
|
.projection()
|
||||||
|
.grants
|
||||||
|
.iter()
|
||||||
|
.any(|grant| match (access, grant) {
|
||||||
|
(
|
||||||
|
PluginTicketAccess::Read,
|
||||||
|
crate::plugin_plan::PluginGrant::TicketRead
|
||||||
|
| crate::plugin_plan::PluginGrant::TicketDataRead { .. },
|
||||||
|
) => true,
|
||||||
|
(
|
||||||
|
PluginTicketAccess::ExtensionWrite,
|
||||||
|
crate::plugin_plan::PluginGrant::TicketDataWrite { .. },
|
||||||
|
) => true,
|
||||||
|
_ => false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn authorize_ticket(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
context: &ServerRequestContext,
|
||||||
|
workspace: &str,
|
||||||
|
reference: &str,
|
||||||
|
access: PluginTicketAccess,
|
||||||
|
) -> crate::Result<String> {
|
||||||
|
let hidden = || policy::failure(PluginHostErrorCode::NotFound);
|
||||||
|
if reference.is_empty() || reference.len() > 512 || reference.chars().any(char::is_control) {
|
||||||
|
return Err(hidden());
|
||||||
|
}
|
||||||
|
let worker = subject_authorization::source_from_context(context).map_err(|e| e.error)?;
|
||||||
|
let ticket_id = api
|
||||||
|
.store
|
||||||
|
.resolve_resource_reference(workspace, WorkspaceResourceKind::Ticket, reference)?
|
||||||
|
.ok_or_else(hidden)?;
|
||||||
|
subject_authorization::authorize(
|
||||||
|
api,
|
||||||
|
&worker,
|
||||||
|
&OperationAuthorizationRequest {
|
||||||
|
operation: match access {
|
||||||
|
PluginTicketAccess::Read => "ticket.read",
|
||||||
|
PluginTicketAccess::ExtensionWrite => "ticket.extension.write",
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
resource: PermissionResource::Ticket {
|
||||||
|
workspace_id: workspace.into(),
|
||||||
|
ticket_id: ticket_id.clone(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|e| match e.error {
|
||||||
|
Error::WorkspacePermissionDenied(_) => hidden(),
|
||||||
|
other => other,
|
||||||
|
})?;
|
||||||
|
Ok(ticket_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_ticket(
|
||||||
|
tx: &rusqlite::Transaction<'_>,
|
||||||
|
workspace: &str,
|
||||||
|
ticket: &str,
|
||||||
|
) -> crate::Result<PluginTicketSnapshot> {
|
||||||
|
let (title, status, body): (String, String, String) = tx.query_row(
|
||||||
|
"SELECT t.title,t.status,e.body FROM typed_tickets t JOIN typed_ticket_events e ON e.workspace_id=t.workspace_id AND e.ticket_id=t.ticket_id AND e.event_index=-1 WHERE t.workspace_id=?1 AND t.ticket_id=?2 AND EXISTS(SELECT 1 FROM workspaces WHERE workspace_id=t.workspace_id AND state='active')",
|
||||||
|
rusqlite::params![workspace,ticket], |row| Ok((row.get(0)?,row.get(1)?,row.get(2)?)),
|
||||||
|
).optional()?.ok_or_else(|| policy::failure(PluginHostErrorCode::NotFound))?;
|
||||||
|
let content_digest = ticket::sqlite_ticket_content_digest(tx, workspace, ticket)?
|
||||||
|
.ok_or_else(|| policy::failure(PluginHostErrorCode::NotFound))?;
|
||||||
|
let truncated = body.len() > PLUGIN_TICKET_BODY_LIMIT;
|
||||||
|
let mut end = body.len().min(PLUGIN_TICKET_BODY_LIMIT);
|
||||||
|
while !body.is_char_boundary(end) {
|
||||||
|
end -= 1;
|
||||||
|
}
|
||||||
|
let case_state = match status.as_str() {
|
||||||
|
"open" => PluginTicketCaseState::Open,
|
||||||
|
"closed" => PluginTicketCaseState::Closed,
|
||||||
|
_ => return Err(policy::failure(PluginHostErrorCode::Unavailable)),
|
||||||
|
};
|
||||||
|
Ok(PluginTicketSnapshot {
|
||||||
|
title,
|
||||||
|
case_state,
|
||||||
|
content_digest,
|
||||||
|
body: body[..end].into(),
|
||||||
|
truncated,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reference(
|
||||||
|
tx: &rusqlite::Transaction<'_>,
|
||||||
|
workspace: &str,
|
||||||
|
admitted: &policy::AdmittedPluginOperation,
|
||||||
|
) -> crate::Result<PluginReference> {
|
||||||
|
let expected_last_operation = tx.query_row(
|
||||||
|
"SELECT last_operation FROM plugin_installations WHERE workspace_id=?1 AND installation_id=?2 AND uninstalled=0",
|
||||||
|
rusqlite::params![workspace,admitted.pin.installation_id()], |row| row.get(0),
|
||||||
|
).optional()?.ok_or_else(|| policy::failure(PluginHostErrorCode::Denied))?;
|
||||||
|
Ok(PluginReference {
|
||||||
|
kind: PluginReferenceKind::Data,
|
||||||
|
owner: String::new(),
|
||||||
|
installation_id: admitted.pin.installation_id().into(),
|
||||||
|
expected_last_operation,
|
||||||
|
package_digest: admitted.pin.package_digest().into(),
|
||||||
|
artifact_digest: admitted.pin.artifact_digest().into(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn schema<'a>(
|
||||||
|
admitted: &'a policy::AdmittedPluginOperation,
|
||||||
|
collection: &PluginDataCollection,
|
||||||
|
) -> crate::Result<&'a PluginDataSchemaMetadata> {
|
||||||
|
admitted
|
||||||
|
.data_collections
|
||||||
|
.iter()
|
||||||
|
.find(|declaration| {
|
||||||
|
declaration.collection == collection.collection
|
||||||
|
&& declaration.schema_id == collection.schema_id
|
||||||
|
&& declaration.schema_version == collection.schema_version
|
||||||
|
})
|
||||||
|
.ok_or_else(|| policy::failure(PluginHostErrorCode::Denied))
|
||||||
|
}
|
||||||
|
fn validate_value(
|
||||||
|
declaration: &PluginDataSchemaMetadata,
|
||||||
|
mutation: &PluginDataMutation,
|
||||||
|
) -> crate::Result<()> {
|
||||||
|
if let PluginDataMutation::Put { value_json, .. } = mutation {
|
||||||
|
if value_json.len() > PLUGIN_DATA_VALUE_LIMIT {
|
||||||
|
return Err(policy::failure(PluginHostErrorCode::QuotaExceeded));
|
||||||
|
}
|
||||||
|
let value: serde_json::Value = serde_json::from_str(value_json)
|
||||||
|
.map_err(|_| policy::failure(PluginHostErrorCode::InvalidInput))?;
|
||||||
|
manifest::plugin::schema::validate_data_value(&declaration.schema, &value)
|
||||||
|
.map_err(|_| policy::failure(PluginHostErrorCode::InvalidInput))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Action {
|
||||||
|
Resolve(PluginTicketAccess),
|
||||||
|
Read,
|
||||||
|
Get(PluginDataAddress),
|
||||||
|
Mutate(PluginDataAddress, String, PluginDataMutation),
|
||||||
|
List(PluginDataCollection, Option<String>, u32),
|
||||||
|
Lookup(PluginDataAddress, String),
|
||||||
|
}
|
||||||
|
impl Action {
|
||||||
|
fn access(&self) -> PluginTicketAccess {
|
||||||
|
match self {
|
||||||
|
Self::Resolve(access) => *access,
|
||||||
|
Self::Mutate(..) | Self::Lookup(..) => PluginTicketAccess::ExtensionWrite,
|
||||||
|
_ => PluginTicketAccess::Read,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn collection(&self) -> Option<&PluginDataCollection> {
|
||||||
|
match self {
|
||||||
|
Self::Get(a) | Self::Mutate(a, ..) | Self::Lookup(a, ..) => Some(&a.collection),
|
||||||
|
Self::List(c, ..) => Some(c),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn execute(
|
||||||
|
api: Arc<WorkspaceApi>,
|
||||||
|
context: ServerRequestContext,
|
||||||
|
workspace: String,
|
||||||
|
request: PluginHostRequest,
|
||||||
|
) -> std::result::Result<PluginHostResponse, PluginHostError> {
|
||||||
|
let (invocation_id, selector, action) = match request {
|
||||||
|
PluginHostRequest::ResolveTicket {
|
||||||
|
invocation_id,
|
||||||
|
ticket_ref,
|
||||||
|
access,
|
||||||
|
} => (invocation_id, ticket_ref, Action::Resolve(access)),
|
||||||
|
PluginHostRequest::ReadTicket {
|
||||||
|
invocation_id,
|
||||||
|
ticket_id,
|
||||||
|
} => (invocation_id, ticket_id, Action::Read),
|
||||||
|
PluginHostRequest::DataGet {
|
||||||
|
invocation_id,
|
||||||
|
ticket_id,
|
||||||
|
address,
|
||||||
|
} => (invocation_id, ticket_id, Action::Get(address)),
|
||||||
|
PluginHostRequest::DataMutate {
|
||||||
|
invocation_id,
|
||||||
|
ticket_id,
|
||||||
|
address,
|
||||||
|
operation_key,
|
||||||
|
mutation,
|
||||||
|
} => (
|
||||||
|
invocation_id,
|
||||||
|
ticket_id,
|
||||||
|
Action::Mutate(address, operation_key, mutation),
|
||||||
|
),
|
||||||
|
PluginHostRequest::DataList {
|
||||||
|
invocation_id,
|
||||||
|
ticket_id,
|
||||||
|
collection,
|
||||||
|
cursor,
|
||||||
|
limit,
|
||||||
|
} => (
|
||||||
|
invocation_id,
|
||||||
|
ticket_id,
|
||||||
|
Action::List(collection, cursor, limit),
|
||||||
|
),
|
||||||
|
PluginHostRequest::DataLookupMutation {
|
||||||
|
invocation_id,
|
||||||
|
ticket_id,
|
||||||
|
address,
|
||||||
|
operation_key,
|
||||||
|
} => (
|
||||||
|
invocation_id,
|
||||||
|
ticket_id,
|
||||||
|
Action::Lookup(address, operation_key),
|
||||||
|
),
|
||||||
|
_ => return Err(invalid()),
|
||||||
|
};
|
||||||
|
// Lookup after expiry must remain distinguishable from NotRecorded. It still
|
||||||
|
// goes through the exact same live owner checks and current Ticket authority.
|
||||||
|
let config = api.config_store.clone();
|
||||||
|
let w = workspace.clone();
|
||||||
|
let id = invocation_id.clone();
|
||||||
|
let record = config
|
||||||
|
.db_executor()
|
||||||
|
.run(move || config.with_conn(|conn| receipt::lookup(conn, &w, &id)))
|
||||||
|
.await
|
||||||
|
.map_err(policy::map_error)?;
|
||||||
|
let admitted = check(
|
||||||
|
api.clone(),
|
||||||
|
context.clone(),
|
||||||
|
workspace.clone(),
|
||||||
|
record.plan_id.clone(),
|
||||||
|
record.installation_id.clone(),
|
||||||
|
record.consumer.clone(),
|
||||||
|
PluginHostOperation::Admit,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if record.execution != *admitted.plan.plan.execution() {
|
||||||
|
return Err(denied());
|
||||||
|
}
|
||||||
|
if !access_granted(&admitted, action.access()) {
|
||||||
|
return Err(not_found());
|
||||||
|
}
|
||||||
|
let config = api.config_store.clone();
|
||||||
|
let executor = config.db_executor();
|
||||||
|
executor.run(move || {
|
||||||
|
// Resolve aliases and the live Subject/delegation ceiling before any
|
||||||
|
// Ticket content or receipt is read. The DB transaction then fences
|
||||||
|
// catalog revoke, cancellation, parent deletion and competing writes.
|
||||||
|
let ticket_id = authorize_ticket(&api, &context, &workspace, &selector, action.access())?;
|
||||||
|
config.with_immediate_transaction(|tx| {
|
||||||
|
let record = receipt::verify_record(tx, &record)?;
|
||||||
|
let tools = match &record.consumer { PluginConsumer::ToolCall { tool_name, .. } => vec![tool_name.clone()], _ => return Err(policy::failure(PluginHostErrorCode::Unsupported)) };
|
||||||
|
let admitted = policy::authorize(tx, &workspace, &record.execution, &record.plan_id, &record.installation_id, &tools, &PluginHostOperation::Admit)?;
|
||||||
|
if record.cancelled { return Err(policy::failure(PluginHostErrorCode::Cancelled)); }
|
||||||
|
if !matches!(action, Action::Lookup(..)) && (record.expired || record.expires_at_ms <= now_ms()) { return Err(policy::failure(PluginHostErrorCode::ReceiptExpired)); }
|
||||||
|
if !tx.query_row("SELECT EXISTS(SELECT 1 FROM typed_tickets t JOIN workspaces w USING(workspace_id) WHERE t.workspace_id=?1 AND t.ticket_id=?2 AND w.state='active')",rusqlite::params![workspace,ticket_id],|row|row.get::<_,bool>(0))? { return Err(policy::failure(PluginHostErrorCode::NotFound)); }
|
||||||
|
if let Some(collection) = action.collection() {
|
||||||
|
let write = matches!(action, Action::Mutate(..) | Action::Lookup(..));
|
||||||
|
if !collection_granted(&admitted, collection, write) { return Err(policy::failure(PluginHostErrorCode::Denied)); }
|
||||||
|
schema(&admitted, collection)?;
|
||||||
|
}
|
||||||
|
match action {
|
||||||
|
Action::Resolve(_) => Ok(PluginHostResponse::TicketResolved { ticket_id }),
|
||||||
|
Action::Read => {
|
||||||
|
if !admitted.pin.projection().grants.contains(&crate::plugin_plan::PluginGrant::TicketRead) { return Err(policy::failure(PluginHostErrorCode::Denied)); }
|
||||||
|
Ok(PluginHostResponse::TicketRead { snapshot: read_ticket(tx, &workspace, &ticket_id)? })
|
||||||
|
}
|
||||||
|
Action::Get(address) => Ok(PluginHostResponse::DataCell { cell: store::get(tx, &store::namespace(&record, &ticket_id, &address), &address.key)? }),
|
||||||
|
Action::Mutate(address, operation_key, mutation) => {
|
||||||
|
validate_value(schema(&admitted, &address.collection)?, &mutation)?;
|
||||||
|
let receipt = store::mutate(tx, &record, &store::namespace(&record, &ticket_id, &address), &address.key, &operation_key, &mutation, &reference(tx, &workspace, &admitted)?, now_ms())?;
|
||||||
|
Ok(PluginHostResponse::DataMutated { receipt })
|
||||||
|
}
|
||||||
|
Action::List(collection, cursor, limit) => {
|
||||||
|
let namespace = store::Namespace { workspace_id: workspace, installation_id: record.installation_id, ticket_id, collection };
|
||||||
|
Ok(PluginHostResponse::DataListed { page: store::list(tx, &namespace, cursor.as_deref(), limit, now_ms())? })
|
||||||
|
}
|
||||||
|
Action::Lookup(address, operation_key) => Ok(PluginHostResponse::DataMutationLookup { outcome: store::lookup_mutation(tx, &record, &store::namespace(&record, &ticket_id, &address), &address.key, &operation_key, now_ms())? }),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}).await.map_err(policy::map_error)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,8 @@ use super::*;
|
|||||||
use crate::{plugin_host as policy, plugin_https as https, plugin_receipt as receipt};
|
use crate::{plugin_host as policy, plugin_https as https, plugin_receipt as receipt};
|
||||||
use rusqlite::OptionalExtension;
|
use rusqlite::OptionalExtension;
|
||||||
use server_api::*;
|
use server_api::*;
|
||||||
|
#[path = "server_plugin_data.rs"]
|
||||||
|
mod data;
|
||||||
|
|
||||||
impl WorkspaceApi {
|
impl WorkspaceApi {
|
||||||
/// Host plan creation after the canonical reservation and grant/Job binding,
|
/// Host plan creation after the canonical reservation and grant/Job binding,
|
||||||
@@ -392,6 +394,14 @@ async fn execute_with_transport(
|
|||||||
// Human management and a Runtime-only (no Worker) source both fail closed.
|
// Human management and a Runtime-only (no Worker) source both fail closed.
|
||||||
subject_authorization::source_from_context(&context).map_err(|_| denied())?;
|
subject_authorization::source_from_context(&context).map_err(|_| denied())?;
|
||||||
match request {
|
match request {
|
||||||
|
request @ (PluginHostRequest::ResolveTicket { .. }
|
||||||
|
| PluginHostRequest::ReadTicket { .. }
|
||||||
|
| PluginHostRequest::DataGet { .. }
|
||||||
|
| PluginHostRequest::DataMutate { .. }
|
||||||
|
| PluginHostRequest::DataList { .. }
|
||||||
|
| PluginHostRequest::DataLookupMutation { .. }) => {
|
||||||
|
data::execute(api, context, workspace, request).await
|
||||||
|
}
|
||||||
PluginHostRequest::Prepare {
|
PluginHostRequest::Prepare {
|
||||||
plan_id,
|
plan_id,
|
||||||
installation_id,
|
installation_id,
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
// proof verification, current Subject permissions and all Host checks are real.
|
// proof verification, current Subject permissions and all Host checks are real.
|
||||||
// Run with `cargo test -p yoi-workspace-server --lib plugin_host_tests`.
|
// Run with `cargo test -p yoi-workspace-server --lib plugin_host_tests`.
|
||||||
// The shared TLS fixture requires openssl; tests never mutate process environment.
|
// The shared TLS fixture requires openssl; tests never mutate process environment.
|
||||||
|
#[path = "server_plugin_data_tests.rs"]
|
||||||
|
mod data;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::plugin_binding::PluginConnectionBinding;
|
use crate::plugin_binding::PluginConnectionBinding;
|
||||||
use crate::plugin_https::{
|
use crate::plugin_https::{
|
||||||
@@ -48,17 +51,6 @@ struct Fixture {
|
|||||||
|
|
||||||
impl Fixture {
|
impl Fixture {
|
||||||
async fn new(binding: Option<PluginConnectionBinding>) -> Self {
|
async fn new(binding: Option<PluginConnectionBinding>) -> Self {
|
||||||
use manifest::plugin::*;
|
|
||||||
let directory = tempfile::tempdir().unwrap();
|
|
||||||
let mut api = test_api(directory.path()).await;
|
|
||||||
register_test_runtime(&api, WorkdirlessFixtureRuntime::RUNTIME_ID).await;
|
|
||||||
let runtime = WorkdirlessFixtureRuntime::default();
|
|
||||||
api.runtime.register_or_replace(runtime.clone());
|
|
||||||
let subject = open_subjektiv_store(&api)
|
|
||||||
.unwrap()
|
|
||||||
.create_subject(crate::subjektiv::SubjectRole::new("plugin-host-integration").unwrap())
|
|
||||||
.unwrap()
|
|
||||||
.id;
|
|
||||||
let https = binding.is_some();
|
let https = binding.is_some();
|
||||||
let (manifest, component, tool) = if https {
|
let (manifest, component, tool) = if https {
|
||||||
(include_str!("../../../resources/plugin/templates/rust-component-https/plugin.toml")
|
(include_str!("../../../resources/plugin/templates/rust-component-https/plugin.toml")
|
||||||
@@ -73,6 +65,27 @@ impl Fixture {
|
|||||||
"example_echo",
|
"example_echo",
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
Self::with_package(binding, manifest, component, tool, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn with_package(
|
||||||
|
binding: Option<PluginConnectionBinding>,
|
||||||
|
manifest: String,
|
||||||
|
component: &[u8],
|
||||||
|
tool: &'static str,
|
||||||
|
ticket_grants: Option<Value>,
|
||||||
|
) -> Self {
|
||||||
|
use manifest::plugin::*;
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let mut api = test_api(directory.path()).await;
|
||||||
|
register_test_runtime(&api, WorkdirlessFixtureRuntime::RUNTIME_ID).await;
|
||||||
|
let runtime = WorkdirlessFixtureRuntime::default();
|
||||||
|
api.runtime.register_or_replace(runtime.clone());
|
||||||
|
let subject = open_subjektiv_store(&api)
|
||||||
|
.unwrap()
|
||||||
|
.create_subject(crate::subjektiv::SubjectRole::new("plugin-host-integration").unwrap())
|
||||||
|
.unwrap()
|
||||||
|
.id;
|
||||||
let package_dir = tempfile::tempdir().unwrap();
|
let package_dir = tempfile::tempdir().unwrap();
|
||||||
fs::write(package_dir.path().join("plugin.toml"), manifest).unwrap();
|
fs::write(package_dir.path().join("plugin.toml"), manifest).unwrap();
|
||||||
fs::write(package_dir.path().join("plugin.component.wasm"), component).unwrap();
|
fs::write(package_dir.path().join("plugin.component.wasm"), component).unwrap();
|
||||||
@@ -105,7 +118,13 @@ impl Fixture {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
commit_config(&api, &installed, tool, binding.as_ref());
|
commit_config_with_ticket_grants(
|
||||||
|
&api,
|
||||||
|
&installed,
|
||||||
|
tool,
|
||||||
|
binding.as_ref(),
|
||||||
|
ticket_grants.as_ref(),
|
||||||
|
);
|
||||||
let Json(created) = create_workspace_worker(
|
let Json(created) = create_workspace_worker(
|
||||||
State(api.clone()),
|
State(api.clone()),
|
||||||
HeaderMap::new(),
|
HeaderMap::new(),
|
||||||
@@ -207,6 +226,7 @@ impl Fixture {
|
|||||||
} else {
|
} else {
|
||||||
Default::default()
|
Default::default()
|
||||||
},
|
},
|
||||||
|
..Grants::default()
|
||||||
},
|
},
|
||||||
deadline: tokio::time::Instant::now() + std::time::Duration::from_secs(20),
|
deadline: tokio::time::Instant::now() + std::time::Duration::from_secs(20),
|
||||||
cancellation: CancellationToken::new(),
|
cancellation: CancellationToken::new(),
|
||||||
@@ -312,7 +332,7 @@ impl Fixture {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn count(&self, table: &str) -> i64 {
|
fn count(&self, table: &str) -> i64 {
|
||||||
assert!(["plugin_invocations", "plugin_effect_receipts"].contains(&table));
|
assert!(["plugin_invocations", "plugin_effect_receipts", "plugin_data_cells", "plugin_data_receipts", "plugin_data_purges"].contains(&table));
|
||||||
self.api
|
self.api
|
||||||
.config_store
|
.config_store
|
||||||
.with_conn(|conn| {
|
.with_conn(|conn| {
|
||||||
@@ -391,16 +411,49 @@ fn commit_config(
|
|||||||
tool: &str,
|
tool: &str,
|
||||||
binding: Option<&PluginConnectionBinding>,
|
binding: Option<&PluginConnectionBinding>,
|
||||||
) {
|
) {
|
||||||
let artifact = &installed.installation.artifacts[0];
|
commit_config_with_ticket_grants(api, installed, tool, binding, None);
|
||||||
let plugins = json!({"enabled":{"integration":{
|
}
|
||||||
|
|
||||||
|
fn commit_config_with_ticket_grants(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
installed: &server_api::PluginOperationResponse,
|
||||||
|
tool: &str,
|
||||||
|
binding: Option<&PluginConnectionBinding>,
|
||||||
|
ticket_grants: Option<&Value>,
|
||||||
|
) {
|
||||||
|
commit_config_for_artifact(
|
||||||
|
api,
|
||||||
|
installed,
|
||||||
|
&installed.installation.artifacts[0],
|
||||||
|
tool,
|
||||||
|
binding,
|
||||||
|
ticket_grants,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn commit_config_for_artifact(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
installed: &server_api::PluginOperationResponse,
|
||||||
|
artifact: &server_api::PluginArtifact,
|
||||||
|
tool: &str,
|
||||||
|
binding: Option<&PluginConnectionBinding>,
|
||||||
|
ticket_grants: Option<&Value>,
|
||||||
|
) {
|
||||||
|
let mut plugins = json!({"enabled":{"integration":{
|
||||||
"installation_id":installed.installation.installation_id,
|
"installation_id":installed.installation.installation_id,
|
||||||
"expected_last_operation":installed.operation_id,
|
"expected_last_operation":installed.operation_id,
|
||||||
"package_digest":artifact.package_digest,"artifact_digest":artifact.artifact_digest,
|
"package_digest":artifact.package_digest,"artifact_digest":artifact.artifact_digest,
|
||||||
"tools":[tool],"logging":false,"config":{},
|
"tools":[tool],"logging":false,"config":{},
|
||||||
"connections":if binding.is_some() { json!({"api":"remote"}) } else { json!({}) }
|
"connections":if binding.is_some() { json!({"api":"remote"}) } else { json!({}) }
|
||||||
}},"bindings":if let Some(binding) = binding { json!({"remote":binding}) } else { json!({}) }});
|
}},"bindings":if let Some(binding) = binding { json!({"remote":binding}) } else { json!({}) }});
|
||||||
let selection = json!({"selections":[{"installation_id":installed.installation.installation_id,
|
let mut selection = json!({"selections":[{"installation_id":installed.installation.installation_id,
|
||||||
"tools":[tool],"logging":false,"https_slots":if binding.is_some() { vec!["api"] } else { vec![] }}]});
|
"tools":[tool],"logging":false,"https_slots":if binding.is_some() { vec!["api"] } else { vec![] }}]});
|
||||||
|
if let Some(grants) = ticket_grants {
|
||||||
|
for (key, value) in grants.as_object().unwrap() {
|
||||||
|
plugins["enabled"]["integration"][key] = value.clone();
|
||||||
|
selection["selections"][0][key] = value.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
let content = format!(
|
let content = format!(
|
||||||
r#"{{
|
r#"{{
|
||||||
plugins = {};
|
plugins = {};
|
||||||
@@ -1250,6 +1303,21 @@ async fn remote_owner_for(
|
|||||||
f: &Fixture,
|
f: &Fixture,
|
||||||
worker_id: &str,
|
worker_id: &str,
|
||||||
workspace: &str,
|
workspace: &str,
|
||||||
|
) -> (Arc<dyn PluginHostOwner>, ServerTask) {
|
||||||
|
remote_owner_for_with_limit(
|
||||||
|
f,
|
||||||
|
worker_id,
|
||||||
|
workspace,
|
||||||
|
yoi_plugin_runtime::owner::PLUGIN_HOST_RESPONSE_BODY_LIMIT,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remote_owner_for_with_limit(
|
||||||
|
f: &Fixture,
|
||||||
|
worker_id: &str,
|
||||||
|
workspace: &str,
|
||||||
|
response_limit: usize,
|
||||||
) -> (Arc<dyn PluginHostOwner>, ServerTask) {
|
) -> (Arc<dyn PluginHostOwner>, ServerTask) {
|
||||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
let address = listener.local_addr().unwrap();
|
let address = listener.local_addr().unwrap();
|
||||||
@@ -1259,7 +1327,7 @@ async fn remote_owner_for(
|
|||||||
}));
|
}));
|
||||||
let client = server_api::ServerApiClient::builder(format!("http://{address}"))
|
let client = server_api::ServerApiClient::builder(format!("http://{address}"))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.response_body_limit(yoi_plugin_runtime::owner::PLUGIN_HOST_RESPONSE_BODY_LIMIT)
|
.response_body_limit(response_limit)
|
||||||
.client(
|
.client(
|
||||||
reqwest::Client::builder()
|
reqwest::Client::builder()
|
||||||
.no_proxy()
|
.no_proxy()
|
||||||
|
|||||||
@@ -311,6 +311,9 @@ pub(super) mod plugin_runtime_tests {
|
|||||||
.plugins
|
.plugins
|
||||||
.selections
|
.selections
|
||||||
.push(manifest::PluginProfileSelection {
|
.push(manifest::PluginProfileSelection {
|
||||||
|
ticket_read: false,
|
||||||
|
ticket_data_read: vec![],
|
||||||
|
ticket_data_write: vec![],
|
||||||
installation_id: f.installed.installation.installation_id.clone(),
|
installation_id: f.installed.installation.installation_id.clone(),
|
||||||
tools: vec![f.tool.into()],
|
tools: vec![f.tool.into()],
|
||||||
logging: false,
|
logging: false,
|
||||||
@@ -834,6 +837,9 @@ pub(super) mod plugin_runtime_tests {
|
|||||||
.plugins
|
.plugins
|
||||||
.selections
|
.selections
|
||||||
.push(manifest::PluginProfileSelection {
|
.push(manifest::PluginProfileSelection {
|
||||||
|
ticket_read: false,
|
||||||
|
ticket_data_read: vec![],
|
||||||
|
ticket_data_write: vec![],
|
||||||
installation_id: f.installed.installation.installation_id.clone(),
|
installation_id: f.installed.installation.installation_id.clone(),
|
||||||
tools: vec![f.tool.into()],
|
tools: vec![f.tool.into()],
|
||||||
logging: false,
|
logging: false,
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ pub use ticket::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
const OLDEST_SCHEMA_VERSION: i64 = 50;
|
const OLDEST_SCHEMA_VERSION: i64 = 50;
|
||||||
const LATEST_SCHEMA_VERSION: i64 = 110;
|
const LATEST_SCHEMA_VERSION: i64 = 111;
|
||||||
const WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME: &str = "durable Workspace Drive grants";
|
const WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME: &str = "durable Workspace Drive grants";
|
||||||
const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs";
|
const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs";
|
||||||
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
|
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
|
||||||
@@ -487,6 +487,11 @@ const MIGRATIONS: &[Migration] = &[
|
|||||||
name: "Worker Plugin launch selection associations",
|
name: "Worker Plugin launch selection associations",
|
||||||
apply: migrate_plugin_launches_v109_to_v110,
|
apply: migrate_plugin_launches_v109_to_v110,
|
||||||
},
|
},
|
||||||
|
Migration {
|
||||||
|
version: 111,
|
||||||
|
name: "Typed Plugin Ticket extension data and atomic mutation receipts",
|
||||||
|
apply: migrate_plugin_data_v110_to_v111,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Both schema-103 identities shipped. Keep the alternate implementation and accept
|
// Both schema-103 identities shipped. Keep the alternate implementation and accept
|
||||||
@@ -15897,6 +15902,49 @@ fn migrate_subject_assignments_v107_to_v108(conn: &Connection) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn verify_plugin_data_schema(conn: &Connection) -> Result<()> {
|
||||||
|
let expected = Connection::open_in_memory()?;
|
||||||
|
expected.execute_batch(crate::plugin_receipt::SCHEMA_SQL)?;
|
||||||
|
expected.execute_batch(crate::plugin_data::SCHEMA_SQL)?;
|
||||||
|
let mut query = expected.prepare("SELECT name,sql FROM sqlite_schema WHERE sql IS NOT NULL AND (name LIKE 'plugin_data_%' OR name='plugin_effect_data_collision') ORDER BY name")?;
|
||||||
|
for item in query.query_map([], |row| {
|
||||||
|
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||||
|
})? {
|
||||||
|
let (name, sql) = item?;
|
||||||
|
let actual: Option<String> = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT sql FROM sqlite_schema WHERE name=?1",
|
||||||
|
[&name],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
if actual.as_deref() != Some(&sql) {
|
||||||
|
return Err(Error::Store(format!(
|
||||||
|
"Plugin extension-data schema has drifted: {name}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn migrate_plugin_data_v110_to_v111(conn: &Connection) -> Result<()> {
|
||||||
|
if current_schema_version(conn)? != 110 {
|
||||||
|
return Err(Error::Store(
|
||||||
|
"Plugin extension-data migration requires schema 110".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
verify_schema_history(conn, 110)?;
|
||||||
|
let tx = conn.unchecked_transaction()?;
|
||||||
|
tx.execute_batch(crate::plugin_data::REFERENCE_MIGRATION_SQL)?;
|
||||||
|
tx.execute_batch(crate::plugin_data::SCHEMA_SQL)?;
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO __yoi_schema_migrations(version,name) VALUES(111,?1)",
|
||||||
|
["Typed Plugin Ticket extension data and atomic mutation receipts"],
|
||||||
|
)?;
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn migrate_plugin_launches_v109_to_v110(conn: &Connection) -> Result<()> {
|
fn migrate_plugin_launches_v109_to_v110(conn: &Connection) -> Result<()> {
|
||||||
if current_schema_version(conn)? != 109 {
|
if current_schema_version(conn)? != 109 {
|
||||||
return Err(Error::Store(
|
return Err(Error::Store(
|
||||||
@@ -15975,6 +16023,8 @@ fn create_workspace_schema_structure(conn: &Connection) -> Result<()> {
|
|||||||
conn.execute_batch(crate::plugin_plan::SCHEMA_SQL)?;
|
conn.execute_batch(crate::plugin_plan::SCHEMA_SQL)?;
|
||||||
conn.execute_batch(crate::plugin_receipt::SCHEMA_SQL)?;
|
conn.execute_batch(crate::plugin_receipt::SCHEMA_SQL)?;
|
||||||
conn.execute_batch(crate::plugin_plan::LAUNCH_SCHEMA_SQL)?;
|
conn.execute_batch(crate::plugin_plan::LAUNCH_SCHEMA_SQL)?;
|
||||||
|
conn.execute_batch(crate::plugin_data::REFERENCE_MIGRATION_SQL)?;
|
||||||
|
conn.execute_batch(crate::plugin_data::SCHEMA_SQL)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17015,7 +17065,8 @@ fn apply_migrations_with_secret_source(
|
|||||||
}
|
}
|
||||||
|
|
||||||
verify_schema_history(conn, LATEST_SCHEMA_VERSION)?;
|
verify_schema_history(conn, LATEST_SCHEMA_VERSION)?;
|
||||||
crate::plugin_catalog::verify(conn)?;
|
crate::plugin_catalog::verify_with_data(conn)?;
|
||||||
|
verify_plugin_data_schema(conn)?;
|
||||||
crate::plugin_plan::verify_plan_schema(conn)?;
|
crate::plugin_plan::verify_plan_schema(conn)?;
|
||||||
crate::plugin_host::verify_receipt_schema(conn)?;
|
crate::plugin_host::verify_receipt_schema(conn)?;
|
||||||
crate::plugin_plan::verify_launch_schema(conn)?;
|
crate::plugin_plan::verify_launch_schema(conn)?;
|
||||||
@@ -19304,6 +19355,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
|||||||
include!("ticket_post_upgrade_tests.rs");
|
include!("ticket_post_upgrade_tests.rs");
|
||||||
include!("migration_entrypoint_tests.rs");
|
include!("migration_entrypoint_tests.rs");
|
||||||
include!("plugin_host_migration_tests.rs");
|
include!("plugin_host_migration_tests.rs");
|
||||||
|
include!("plugin_data_migration_tests.rs");
|
||||||
include!("legacy_evidence_column_tests.rs");
|
include!("legacy_evidence_column_tests.rs");
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -19721,6 +19773,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
|||||||
WorkspaceSchemaMigrationStep {version:108, name:"Subject Ticket responsibility and current-body notification routing".into()},
|
WorkspaceSchemaMigrationStep {version:108, name:"Subject Ticket responsibility and current-body notification routing".into()},
|
||||||
WorkspaceSchemaMigrationStep {version:109, name:"Durable Plugin Host invocations and HTTPS effect receipts".into()},
|
WorkspaceSchemaMigrationStep {version:109, name:"Durable Plugin Host invocations and HTTPS effect receipts".into()},
|
||||||
WorkspaceSchemaMigrationStep {version:110, name:"Worker Plugin launch selection associations".into()},
|
WorkspaceSchemaMigrationStep {version:110, name:"Worker Plugin launch selection associations".into()},
|
||||||
|
WorkspaceSchemaMigrationStep {version:111, name:"Typed Plugin Ticket extension data and atomic mutation receipts".into()},
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -19841,6 +19894,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
|||||||
(108, "Subject Ticket responsibility and current-body notification routing".to_string()),
|
(108, "Subject Ticket responsibility and current-body notification routing".to_string()),
|
||||||
(109, "Durable Plugin Host invocations and HTTPS effect receipts".to_string()),
|
(109, "Durable Plugin Host invocations and HTTPS effect receipts".to_string()),
|
||||||
(110, "Worker Plugin launch selection associations".to_string()),
|
(110, "Worker Plugin launch selection associations".to_string()),
|
||||||
|
(111, "Typed Plugin Ticket extension data and atomic mutation receipts".to_string()),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
assert!(!table_exists(conn, "trusted_runtime_records")?);
|
assert!(!table_exists(conn, "trusted_runtime_records")?);
|
||||||
@@ -23132,7 +23186,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
|||||||
(107, "Immutable Worker Plugin plans and retained pins".into()),
|
(107, "Immutable Worker Plugin plans and retained pins".into()),
|
||||||
(108, "Subject Ticket responsibility and current-body notification routing".into()),
|
(108, "Subject Ticket responsibility and current-body notification routing".into()),
|
||||||
(109, "Durable Plugin Host invocations and HTTPS effect receipts".into()),
|
(109, "Durable Plugin Host invocations and HTTPS effect receipts".into()),
|
||||||
(110, "Worker Plugin launch selection associations".into())]);
|
(110, "Worker Plugin launch selection associations".into()),
|
||||||
|
(111, "Typed Plugin Ticket extension data and atomic mutation receipts".into())]);
|
||||||
for (table, rows) in unchanged.iter().zip(before.iter()) {
|
for (table, rows) in unchanged.iter().zip(before.iter()) {
|
||||||
assert_eq!(&stored_fixture_rows(conn, table), rows, "preserve {table}");
|
assert_eq!(&stored_fixture_rows(conn, table), rows, "preserve {table}");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1071,7 +1071,7 @@ mod subject_assignment_migration_tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(history.len(), host.len() + expected.len());
|
assert_eq!(history.len(), host.len() + expected.len());
|
||||||
assert_component14_append(conn, component);
|
assert_component14_append(conn, component);
|
||||||
crate::plugin_catalog::verify(conn).unwrap();
|
crate::plugin_catalog::verify_with_data(conn).unwrap();
|
||||||
plugin_plan::verify_plan_schema(conn).unwrap();
|
plugin_plan::verify_plan_schema(conn).unwrap();
|
||||||
verify(conn).unwrap();
|
verify(conn).unwrap();
|
||||||
assert_health(conn);
|
assert_health(conn);
|
||||||
@@ -1130,6 +1130,10 @@ mod subject_assignment_migration_tests {
|
|||||||
};
|
};
|
||||||
tail.push((109, "Durable Plugin Host invocations and HTTPS effect receipts".into()));
|
tail.push((109, "Durable Plugin Host invocations and HTTPS effect receipts".into()));
|
||||||
tail.push((110, "Worker Plugin launch selection associations".into()));
|
tail.push((110, "Worker Plugin launch selection associations".into()));
|
||||||
|
tail.push((
|
||||||
|
111,
|
||||||
|
"Typed Plugin Ticket extension data and atomic mutation receipts".into(),
|
||||||
|
));
|
||||||
tail
|
tail
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1179,7 +1183,7 @@ mod subject_assignment_migration_tests {
|
|||||||
fn plugin_schema(conn: &Connection) -> Rows {
|
fn plugin_schema(conn: &Connection) -> Rows {
|
||||||
fixture_query_rows(
|
fixture_query_rows(
|
||||||
conn,
|
conn,
|
||||||
"SELECT type,name,tbl_name,rootpage,sql FROM sqlite_schema WHERE tbl_name LIKE 'plugin_%' AND tbl_name NOT IN ('plugin_invocations','plugin_effect_receipts') ORDER BY type,name",
|
"SELECT type,name,tbl_name,rootpage,sql FROM sqlite_schema WHERE tbl_name LIKE 'plugin_%' AND tbl_name NOT LIKE 'plugin_data_%' AND tbl_name NOT IN ('plugin_invocations','plugin_effect_receipts','plugin_references') ORDER BY type,name",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1189,6 +1193,9 @@ mod subject_assignment_migration_tests {
|
|||||||
let before = snapshot(&conn);
|
let before = snapshot(&conn);
|
||||||
let rows = data(&conn, true);
|
let rows = data(&conn, true);
|
||||||
let plans = plugin_schema(&conn);
|
let plans = plugin_schema(&conn);
|
||||||
|
// Migration111 intentionally rebuilds the reference guard to admit
|
||||||
|
// data owners; its complete row set must still survive unchanged.
|
||||||
|
let references = stored_fixture_rows(&conn, "plugin_references");
|
||||||
let contracts = principal_table_contracts(&conn);
|
let contracts = principal_table_contracts(&conn);
|
||||||
let host = host_history(&conn);
|
let host = host_history(&conn);
|
||||||
let component = component_history(&conn);
|
let component = component_history(&conn);
|
||||||
@@ -1205,6 +1212,8 @@ mod subject_assignment_migration_tests {
|
|||||||
assert_data(&conn, &rows);
|
assert_data(&conn, &rows);
|
||||||
assert_final(&conn, 107, &host, &component);
|
assert_final(&conn, 107, &host, &component);
|
||||||
assert_eq!(plugin_schema(&conn), plans);
|
assert_eq!(plugin_schema(&conn), plans);
|
||||||
|
assert_eq!(stored_fixture_rows(&conn, "plugin_references"), references);
|
||||||
|
verify_plugin_data_schema(&conn).unwrap();
|
||||||
assert_eq!(principal_table_contracts(&conn), contracts);
|
assert_eq!(principal_table_contracts(&conn), contracts);
|
||||||
let final_snapshot = snapshot(&conn);
|
let final_snapshot = snapshot(&conn);
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ use crate::{Error, Result};
|
|||||||
/// Explicit domain-owned purge inventory. The deletion operation tombstone is intentionally
|
/// Explicit domain-owned purge inventory. The deletion operation tombstone is intentionally
|
||||||
/// excluded so retries and audit remain available after the Workspace row is gone.
|
/// excluded so retries and audit remain available after the Workspace row is gone.
|
||||||
const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
|
const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
|
||||||
|
"plugin_data_receipts",
|
||||||
|
"plugin_data_purges",
|
||||||
|
"plugin_data_holders",
|
||||||
|
"plugin_data_cells",
|
||||||
|
"plugin_data_cursors",
|
||||||
"plugin_effect_receipts",
|
"plugin_effect_receipts",
|
||||||
"plugin_invocations",
|
"plugin_invocations",
|
||||||
"worker_plugin_launches",
|
"worker_plugin_launches",
|
||||||
@@ -512,6 +517,8 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore {
|
|||||||
// commit; update guards remain installed throughout.
|
// commit; update guards remain installed throughout.
|
||||||
let mut ticket_delete_guards = Vec::new();
|
let mut ticket_delete_guards = Vec::new();
|
||||||
for (table, trigger) in [
|
for (table, trigger) in [
|
||||||
|
("plugin_data_receipts", "plugin_data_receipt_retained"),
|
||||||
|
("plugin_data_purges", "plugin_data_purge_retained"),
|
||||||
("plugin_effect_receipts", "plugin_effect_retained"),
|
("plugin_effect_receipts", "plugin_effect_retained"),
|
||||||
("plugin_invocations", "plugin_invocation_retained"),
|
("plugin_invocations", "plugin_invocation_retained"),
|
||||||
("worker_plugin_launches", "worker_plugin_launch_retained"),
|
("worker_plugin_launches", "worker_plugin_launch_retained"),
|
||||||
|
|||||||
+1027
-1
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,9 @@ let
|
|||||||
artifact_digest = String;
|
artifact_digest = String;
|
||||||
tools = [...String];
|
tools = [...String];
|
||||||
logging = Bool default false;
|
logging = Bool default false;
|
||||||
|
ticket_read = Bool default false;
|
||||||
|
ticket_data_read = [...{ collection = String; schema_id = String; schema_version = String; }] default [];
|
||||||
|
ticket_data_write = [...{ collection = String; schema_id = String; schema_version = String; }] default [];
|
||||||
connections = { ...String } default {};
|
connections = { ...String } default {};
|
||||||
config = { ...Unknown } default {};
|
config = { ...Unknown } default {};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ let
|
|||||||
plugins = { selections = [...{
|
plugins = { selections = [...{
|
||||||
installation_id = String; tools = [...String]; logging = Bool;
|
installation_id = String; tools = [...String]; logging = Bool;
|
||||||
https_slots = [...String];
|
https_slots = [...String];
|
||||||
|
ticket_read = Bool default false;
|
||||||
|
ticket_data_read = [...{ collection = String; schema_id = String; schema_version = String; }] default [];
|
||||||
|
ticket_data_write = [...{ collection = String; schema_id = String; schema_version = String; }] default [];
|
||||||
# 非秘密のoverrideは補完用のshapeではなく、導入済みpackageの
|
# 非秘密のoverrideは補完用のshapeではなく、導入済みpackageの
|
||||||
# closed typed schemaで検証する。
|
# closed typed schemaで検証する。
|
||||||
config = { ...Unknown };
|
config = { ...Unknown };
|
||||||
|
|||||||
@@ -257,6 +257,8 @@ loggingはlevel、event code、安全なmessageとscalar fieldsに限定する
|
|||||||
|
|
||||||
## 9. 付随データの契約
|
## 9. 付随データの契約
|
||||||
|
|
||||||
|
Ticket拡張の機械的契約は[追加world](wit-ticket/yoi-plugin-v2.1.wit)と[resource imports](wit-ticket/deps/yoi-host-v2.1/yoi-host-v2.1.wit)、`server-api::plugin_data`に置く。旧`@2.0.0` worldへのimport追加や保存pinへのgrant補完はしない。package保存formatのschema versionと、Component world/APIのexact versionは別の境界である。
|
||||||
|
|
||||||
Server拡張はdomain objectに関連付けるPlugin所有のtyped dataとし、既存tableへの任意column追加では実現しない。物理保存schemaはHostが管理する。`feature-storage`のnative migration/SQLite handleは信頼済みHost用でありguestへ公開しない。
|
Server拡張はdomain objectに関連付けるPlugin所有のtyped dataとし、既存tableへの任意column追加では実現しない。物理保存schemaはHostが管理する。`feature-storage`のnative migration/SQLite handleは信頼済みHost用でありguestへ公開しない。
|
||||||
|
|
||||||
addressはHost contextのWorkspace/installationと、認可済みobject resource、宣言collection、schema id/version、keyから決まる。schema id/versionも物理・論理namespaceに含め、新旧artifactが同じcellを別schemaで読み書きしない。guestにWorkspace selectorやraw table名を与えない。最初のobject kindはTicketとし、Hostが存在・Workspace・callerのread/write権限を検証したTicket resourceだけを使う。一般objectへの拡張はadapterと権限を追加して行う。
|
addressはHost contextのWorkspace/installationと、認可済みobject resource、宣言collection、schema id/version、keyから決まる。schema id/versionも物理・論理namespaceに含め、新旧artifactが同じcellを別schemaで読み書きしない。guestにWorkspace selectorやraw table名を与えない。最初のobject kindはTicketとし、Hostが存在・Workspace・callerのread/write権限を検証したTicket resourceだけを使う。一般objectへの拡張はadapterと権限を追加して行う。
|
||||||
@@ -265,7 +267,7 @@ addressはHost contextのWorkspace/installationと、認可済みobject resource
|
|||||||
|
|
||||||
`ticket-read.read(borrow<ticket>)`はread権限を再検査し、title、case state、content digest、bounded bodyとtruncated flagだけを返す専用read APIとする。thread、添付、認証・管理情報、将来増えるfieldを汎用serializeで公開しない。上限は64 KiB、完全本文が必要なら本APIではunsupportedとする。eventの削除通知自体は安全なmetadataとして配送できるが、削除済みTicket resourceをresolve/readできる意味ではない。
|
`ticket-read.read(borrow<ticket>)`はread権限を再検査し、title、case state、content digest、bounded bodyとtruncated flagだけを返す専用read APIとする。thread、添付、認証・管理情報、将来増えるfieldを汎用serializeで公開しない。上限は64 KiB、完全本文が必要なら本APIではunsupportedとする。eventの削除通知自体は安全なmetadataとして配送できるが、削除済みTicket resourceをresolve/readできる意味ではない。
|
||||||
|
|
||||||
- packageはcollectionごとにschema id/versionとclosed typed schemaを宣言する。data valueのJSONはそのschemaで検証する。
|
- packageはcollectionごとにschema id/versionとclosed typed schemaを宣言する。data valueのJSONはそのschemaで検証する。同installationの同collection/schema id/versionはrevoked artifactを含め同一schemaへ束縛し、artifact登録transactionで内容差を拒否する。exact artifactだけでは共有cellの意味を固定できないためである。value上限64 KiBはJSONのUTF-8表現(escapeを含む)のbyte数とし、listは最大100件、Remote envelopeの再escapeを含む有限response budgetを適用する。
|
||||||
- APIは`get`、`put(expected_last_write | never_written, operation_key, value)`、`delete(expected_last_write, operation_key)`、bounded `list(cursor, limit)`。任意query、SQL、cross-object transactionは公開しない。
|
- APIは`get`、`put(expected_last_write | never_written, operation_key, value)`、`delete(expected_last_write, operation_key)`、bounded `list(cursor, limit)`。任意query、SQL、cross-object transactionは公開しない。
|
||||||
- 同じcellへのput/deleteを確定した最終operation identityを、書込みtransaction内で観測値と比較する。CAS成功とoperation receiptを同じtransactionで保存し、自動読直しによるlast-write-winsへ変えない。同じ内容の再書込みや削除・再作成を時刻/内容hashだけで区別できないため、このoperationを比較する。
|
- 同じcellへのput/deleteを確定した最終operation identityを、書込みtransaction内で観測値と比較する。CAS成功とoperation receiptを同じtransactionで保存し、自動読直しによるlast-write-winsへ変えない。同じ内容の再書込みや削除・再作成を時刻/内容hashだけで区別できないため、このoperationを比較する。
|
||||||
- deleteは最終delete operationのtombstoneを保持する。再作成はそのoperationを期待値にし、`never_written`を通さない。これによりABAを防ぐ。`get`は対象objectの認可成功後に限り、present(value, last-write)、never-written、deleted(last-delete)を区別する。不可視objectと不存在は同じnot-foundにする。
|
- deleteは最終delete operationのtombstoneを保持する。再作成はそのoperationを期待値にし、`never_written`を通さない。これによりABAを防ぐ。`get`は対象objectの認可成功後に限り、present(value, last-write)、never-written、deleted(last-delete)を区別する。不可視objectと不存在は同じnot-foundにする。
|
||||||
@@ -276,7 +278,7 @@ addressはHost contextのWorkspace/installationと、認可済みobject resource
|
|||||||
- 親object削除とPlugin data書込みはowner側の削除fenceで競合を解決する。削除後に新しい子dataを作らず、retention後にHostが回収する。
|
- 親object削除とPlugin data書込みはowner側の削除fenceで競合を解決する。削除後に新しい子dataを作らず、retention後にHostが回収する。
|
||||||
- schemaの破壊的変更は新しいschema version/namespaceとする。既存dataをguest起動時にmigrationしない。移行は新旧schemaを明示した認可付き操作として後続で設計し、初期APIでは自動変換しない。
|
- schemaの破壊的変更は新しいschema version/namespaceとする。既存dataをguest起動時にmigrationしない。移行は新旧schemaを明示した認可付き操作として後続で設計し、初期APIでは自動変換しない。
|
||||||
- rollbackで旧artifactを使う場合も元schemaだけを読む。最新schemaへ読み替えない。
|
- rollbackで旧artifactを使う場合も元schemaだけを読む。最新schemaへ読み替えない。
|
||||||
- revoke/disableではdataを消さない。Workspace管理者の明示purgeをoperation keyと期待data-generationで行い、先に実行をrevoke/fenceする。purge receiptを残し、uninstallとの一操作への暗黙統合をしない。
|
- revoke/disableではdataを消さない。Workspace管理者の明示purgeは先に全artifactをrevoke/fenceし、その後のtransactionで観測したinstallation最終catalog operationと、全cell(tombstone含む)のnamespace/key/最終write operation/deleted状態の順序付き集合digest・件数を期待値として比較する。新規writeだけでなく同一値の再書込みやdelete/recreateも区別し、汎用更新番号は導入しない。operation keyによるpurge receiptを残し、uninstallとの一操作への暗黙統合をしない。
|
||||||
|
|
||||||
## 10. commit済みeventとService
|
## 10. commit済みeventとService
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package yoi:host@2.1.0;
|
||||||
|
|
||||||
|
/// Handles are invocation-local selectors, not durable authorization.
|
||||||
|
interface objects {
|
||||||
|
use yoi:host/errors@2.0.0.{host-error};
|
||||||
|
enum access { read, extension-write }
|
||||||
|
resource ticket;
|
||||||
|
resolve-ticket: func(ticket-ref: string, requested: access) -> result<ticket, host-error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ticket-read {
|
||||||
|
use yoi:host/errors@2.0.0.{host-error};
|
||||||
|
use objects.{ticket};
|
||||||
|
enum case-state { open, closed }
|
||||||
|
record snapshot {
|
||||||
|
title: string,
|
||||||
|
state: case-state,
|
||||||
|
content-digest: string,
|
||||||
|
body: string,
|
||||||
|
truncated: bool,
|
||||||
|
}
|
||||||
|
read: func(target: borrow<ticket>) -> result<snapshot, host-error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface extension-data {
|
||||||
|
use yoi:host/errors@2.0.0.{host-error};
|
||||||
|
use objects.{ticket};
|
||||||
|
record collection {
|
||||||
|
collection: string,
|
||||||
|
schema-id: string,
|
||||||
|
schema-version: string,
|
||||||
|
}
|
||||||
|
record address { namespace: collection, key: string }
|
||||||
|
variant expectation { never-written, last-write(string) }
|
||||||
|
record present-cell { value-json: string, last-write: string }
|
||||||
|
variant cell { never-written, present(present-cell), deleted(string) }
|
||||||
|
record receipt { mutation-id: string, result-digest: string }
|
||||||
|
variant mutation-lookup { not-recorded, committed(receipt), expired }
|
||||||
|
record entry { key: string, value: cell }
|
||||||
|
record page { entries: list<entry>, next-cursor: option<string> }
|
||||||
|
get: func(target: borrow<ticket>, cell-address: address) -> result<cell, host-error>;
|
||||||
|
put: func(target: borrow<ticket>, cell-address: address, expected: expectation, operation-key: string, value-json: string) -> result<receipt, host-error>;
|
||||||
|
delete: func(target: borrow<ticket>, cell-address: address, expected-last-write: string, operation-key: string) -> result<receipt, host-error>;
|
||||||
|
%list: func(target: borrow<ticket>, namespace: collection, cursor: option<string>, limit: u32) -> result<page, host-error>;
|
||||||
|
lookup-mutation: func(target: borrow<ticket>, cell-address: address, operation-key: string) -> result<mutation-lookup, host-error>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package yoi:host@2.0.0;
|
||||||
|
|
||||||
|
interface errors {
|
||||||
|
enum code {
|
||||||
|
invalid-input, denied, not-found, conflict, unsupported,
|
||||||
|
quota-exceeded, deadline-exceeded, cancelled, unavailable,
|
||||||
|
indeterminate, receipt-expired,
|
||||||
|
}
|
||||||
|
enum effect-observation { not-dispatched, response-observed, indeterminate }
|
||||||
|
record host-error {
|
||||||
|
code: code,
|
||||||
|
message: string,
|
||||||
|
diagnostic-id: string,
|
||||||
|
effect: effect-observation,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface connections {
|
||||||
|
use errors.{host-error};
|
||||||
|
/// Call-local resource. No serialization, persistence or cross-Worker transfer.
|
||||||
|
resource connection;
|
||||||
|
open: func(slot: string) -> result<connection, host-error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface http {
|
||||||
|
use connections.{connection};
|
||||||
|
use errors.{host-error};
|
||||||
|
enum method { get, head, post, put, patch, delete, options }
|
||||||
|
record pair { name: string, value: string }
|
||||||
|
record request {
|
||||||
|
method: method,
|
||||||
|
path: string,
|
||||||
|
query: list<pair>,
|
||||||
|
headers: list<pair>,
|
||||||
|
body: list<u8>,
|
||||||
|
timeout-ms: u32,
|
||||||
|
effect-key: string,
|
||||||
|
}
|
||||||
|
record response {
|
||||||
|
status: u16,
|
||||||
|
headers: list<pair>,
|
||||||
|
body: list<u8>,
|
||||||
|
}
|
||||||
|
/// Host authorizes each operation and does not automatically retry or follow redirects.
|
||||||
|
send: func(target: borrow<connection>, input: request) -> result<response, host-error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface logging {
|
||||||
|
use errors.{host-error};
|
||||||
|
enum level { trace, debug, info, warn, error }
|
||||||
|
variant scalar { text(string), boolean(bool), signed(s64), unsigned(u64), number(f64) }
|
||||||
|
record field { name: string, value: scalar }
|
||||||
|
record entry {
|
||||||
|
level: level,
|
||||||
|
event-code: string,
|
||||||
|
message: string,
|
||||||
|
fields: list<field>,
|
||||||
|
}
|
||||||
|
/// Bounded diagnostic data, not model context. Never include secrets or raw payloads.
|
||||||
|
log: func(input: entry) -> result<_, host-error>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package yoi:plugin@2.0.0;
|
||||||
|
|
||||||
|
/// Only plugin-specific payloads use schema-validated JSON. Context is not authority.
|
||||||
|
interface tool {
|
||||||
|
record invocation {
|
||||||
|
invocation-id: string,
|
||||||
|
tool-name: string,
|
||||||
|
input-json: string,
|
||||||
|
config-json: string,
|
||||||
|
remaining-ms: u32,
|
||||||
|
}
|
||||||
|
record output {
|
||||||
|
summary: string,
|
||||||
|
content: option<string>,
|
||||||
|
structured-json: option<string>,
|
||||||
|
}
|
||||||
|
record tool-error {
|
||||||
|
code: string,
|
||||||
|
message: string,
|
||||||
|
}
|
||||||
|
invoke: func(input: invocation) -> result<output, tool-error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure computation: no ambient WASI or unused Host capabilities.
|
||||||
|
world tool-plugin {
|
||||||
|
export tool;
|
||||||
|
}
|
||||||
|
|
||||||
|
world https-tool-plugin {
|
||||||
|
import yoi:host/errors@2.0.0;
|
||||||
|
import yoi:host/connections@2.0.0;
|
||||||
|
import yoi:host/http@2.0.0;
|
||||||
|
export tool;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Logging is an explicit capability, never an ambient console.
|
||||||
|
world logged-tool-plugin {
|
||||||
|
import yoi:host/errors@2.0.0;
|
||||||
|
import yoi:host/logging@2.0.0;
|
||||||
|
export tool;
|
||||||
|
}
|
||||||
|
|
||||||
|
world https-logged-tool-plugin {
|
||||||
|
import yoi:host/errors@2.0.0;
|
||||||
|
import yoi:host/connections@2.0.0;
|
||||||
|
import yoi:host/http@2.0.0;
|
||||||
|
import yoi:host/logging@2.0.0;
|
||||||
|
export tool;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package yoi:plugin@2.1.0;
|
||||||
|
|
||||||
|
/// Additive exact world: never augment an old @2.0.0 pin's imports or grants.
|
||||||
|
world ticket-tool-plugin {
|
||||||
|
include yoi:plugin/tool-plugin@2.0.0;
|
||||||
|
import yoi:host/errors@2.0.0;
|
||||||
|
import yoi:host/objects@2.1.0;
|
||||||
|
import yoi:host/ticket-read@2.1.0;
|
||||||
|
import yoi:host/extension-data@2.1.0;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:1053b09bb77ff0fc09fec2d3f96af04b76974c5ea52870f805381302e1b29fe4)
|
// Input contract: 0.1.0 (sha256:2373d17776c7822f8289243b5f79b6227bb78eec1ce101c0837e5abbb2af2350)
|
||||||
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||||
|
|||||||
@@ -19,9 +19,17 @@ export type PluginHttpMethod = "GET" | "HEAD" | "POST" | "PUT" | "PATCH" | "DELE
|
|||||||
|
|
||||||
export type PluginConnectionMetadata = { name: string, methods: Array<PluginHttpMethod>, path_prefixes: Array<string>, };
|
export type PluginConnectionMetadata = { name: string, methods: Array<PluginHttpMethod>, path_prefixes: Array<string>, };
|
||||||
|
|
||||||
export type PluginGrantMetadata = { "kind": "logging" } | { "kind": "https", slot: string, };
|
export type PluginGrantMetadata = { "kind": "logging" } | { "kind": "https", slot: string, } | { "kind": "ticket_read" } | { "kind": "ticket_data_read", collection: string, schema_id: string, schema_version: string, } | { "kind": "ticket_data_write", collection: string, schema_id: string, schema_version: string, };
|
||||||
|
|
||||||
export type PluginArtifact = { package_id: string, name: string, version: string, description: string | null, package_digest: string, artifact_digest: string, artifact_size: number, schema_version: number, world: string, required_imports: Array<string>, tools: Array<PluginToolMetadata>, connections: Array<PluginConnectionMetadata>, requested_grants: Array<PluginGrantMetadata>, config_schema: PluginSchemaContribution, revoked: boolean, revocation_reason: string | null, };
|
export type PluginDataSchemaMetadata = { collection: string, schema_id: string, schema_version: string, schema: unknown, };
|
||||||
|
|
||||||
|
export type PluginDataPurgeState = { installation_last_operation: string, cells_digest: string, cell_count: number, };
|
||||||
|
|
||||||
|
export type PluginDataPurgeRequest = { operation_key: string, expected: PluginDataPurgeState, };
|
||||||
|
|
||||||
|
export type PluginDataPurgeResponse = { purge_id: string, observed: PluginDataPurgeState, };
|
||||||
|
|
||||||
|
export type PluginArtifact = { package_id: string, name: string, version: string, description: string | null, package_digest: string, artifact_digest: string, artifact_size: number, schema_version: number, world: string, required_imports: Array<string>, tools: Array<PluginToolMetadata>, connections: Array<PluginConnectionMetadata>, requested_grants: Array<PluginGrantMetadata>, data_collections: Array<PluginDataSchemaMetadata>, config_schema: PluginSchemaContribution, revoked: boolean, revocation_reason: string | null, };
|
||||||
|
|
||||||
export type PluginUploadResponse = { upload_id: string, expires_at: string, artifact: PluginArtifact, };
|
export type PluginUploadResponse = { upload_id: string, expires_at: string, artifact: PluginArtifact, };
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:1053b09bb77ff0fc09fec2d3f96af04b76974c5ea52870f805381302e1b29fe4)
|
// Input contract: 0.1.0 (sha256:2373d17776c7822f8289243b5f79b6227bb78eec1ce101c0837e5abbb2af2350)
|
||||||
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:1053b09bb77ff0fc09fec2d3f96af04b76974c5ea52870f805381302e1b29fe4)
|
// Input contract: 0.1.0 (sha256:2373d17776c7822f8289243b5f79b6227bb78eec1ce101c0837e5abbb2af2350)
|
||||||
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:1053b09bb77ff0fc09fec2d3f96af04b76974c5ea52870f805381302e1b29fe4)
|
// Input contract: 0.1.0 (sha256:2373d17776c7822f8289243b5f79b6227bb78eec1ce101c0837e5abbb2af2350)
|
||||||
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:1053b09bb77ff0fc09fec2d3f96af04b76974c5ea52870f805381302e1b29fe4)
|
// Input contract: 0.1.0 (sha256:2373d17776c7822f8289243b5f79b6227bb78eec1ce101c0837e5abbb2af2350)
|
||||||
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||||
|
|||||||
Reference in New Issue
Block a user