feat: verify household contract and document secure Grocy setup

This commit is contained in:
LucaPisl
2026-10-01 02:16:02 +03:00
parent 521087b4da
commit 02891b4357
26 changed files with 1342 additions and 144 deletions

View File

@@ -52,6 +52,22 @@ pub async fn run(client: &ApiClient, call: &ValidatedCall) -> Result<ToolOutput>
{
let read = readback("/stock/products/{productId}", "productId", id);
let details = client.request(read).await?.json()?;
if call.op.name == "stock_merge" {
let other = super::records::get_record(
client,
"products",
&call.args["product_id_to_remove"],
)
.await?;
if super::validation::record_id(&details["product"]["qu_id_stock"])?
!= super::validation::record_id(&other["qu_id_stock"])?
{
return Err(AppError::new(
"UNIT_CONVERSION_REQUIRED",
"Products must use the same stock unit before merging. Change the removed product's unit using a valid conversion first.",
));
}
}
if let Some(unit) = call.args.get("quantity_unit_id") {
apply_conversion(client, call, &details, &mut r, unit).await?;
}
@@ -72,6 +88,34 @@ pub async fn run(client: &ApiClient, call: &ValidatedCall) -> Result<ToolOutput>
.request(readback("/stock/entry/{entryId}", "entryId", id))
.await?
.json()?;
// Grocy's entry PUT replaces these values rather than merging them.
// Preserve omitted fields from the preflight read (no cross-client revision guarantee).
if call.op.name == "stock_entry_put"
&& let RequestBody::Json(body) = &mut r.body
{
for key in [
"best_before_date",
"location_id",
"shopping_location_id",
"price",
"purchased_date",
"note",
] {
if body.get(key).is_none() {
body[key] = details[key].clone();
}
}
if body.get("open").is_none() {
body["open"] = json!(details["open"].as_bool().unwrap_or_else(|| {
details["open"].as_i64().unwrap_or_else(|| {
details["open"]
.as_str()
.and_then(|v| v.parse().ok())
.unwrap_or(0)
}) != 0
}));
}
}
if let Some(unit) = call.args.get("quantity_unit_id") {
let details = json!({"product":super::records::get_record(client,"products",&details["product_id"]).await?});
apply_conversion(client, call, &details, &mut r, unit).await?;
@@ -86,6 +130,10 @@ pub async fn run(client: &ApiClient, call: &ValidatedCall) -> Result<ToolOutput>
.or_else(|| call.args.get("chore_id_to_keep"))
{
super::records::get_record(client, "chores", id).await?;
if call.op.name == "chore_merge" {
super::records::get_record(client, "chores", &call.args["chore_id_to_remove"])
.await?;
}
verify = Some(readback("/chores/{choreId}", "choreId", id));
} else if let Some(id) = call.args.get("battery_id") {
super::records::get_record(client, "batteries", id).await?;
@@ -139,10 +187,10 @@ pub async fn run(client: &ApiClient, call: &ValidatedCall) -> Result<ToolOutput>
super::records::get_record(client, "locations", id).await?;
}
}
if call.op.name.starts_with("stock_add") || call.op.name.starts_with("stock_inventory") {
if let RequestBody::Json(body) = &mut r.body {
body["stock_label_type"] = json!(1);
}
if (call.op.name.starts_with("stock_add") || call.op.name.starts_with("stock_inventory"))
&& let RequestBody::Json(body) = &mut r.body
{
body["stock_label_type"] = json!(1);
}
}
if call.op.mutates && call.op.name.starts_with("shopping_") {
@@ -175,15 +223,15 @@ pub async fn run(client: &ApiClient, call: &ValidatedCall) -> Result<ToolOutput>
};
return Ok(ToolOutput::data(data));
}
if call.op.name == "stock_copy" {
if let Some(id) = receipt.get("created_object_id") {
verify = Some(readback("/stock/products/{productId}", "productId", id));
}
if call.op.name == "stock_copy"
&& let Some(id) = receipt.get("created_object_id")
{
verify = Some(readback("/stock/products/{productId}", "productId", id));
}
if call.op.name == "recipe_copy" {
if let Some(id) = receipt.get("created_object_id") {
verify = Some(readback("/recipes/{recipeId}/fulfillment", "recipeId", id));
}
if call.op.name == "recipe_copy"
&& let Some(id) = receipt.get("created_object_id")
{
verify = Some(readback("/recipes/{recipeId}/fulfillment", "recipeId", id));
}
let (data, verification) = if let Some(q) = verify {
match client.request(q).await.and_then(|r| r.json()) {

View File

@@ -42,7 +42,7 @@ pub fn operations(mode: AccessMode) -> Vec<OperationSpec> {
properties = json!({"group":{"type":"string","enum":["equipmentmanuals","recipepictures","productpictures","userfiles"]},"filename":{"type":"string","maxLength":255}});
required.extend(["group", "filename"]);
if name == "file_upload" {
properties["content_base64"] = json!({"type":"string","maxLength":((BINARY_LIMIT+2)/3)*4,"description":"Standard base64 of actual file bytes, at most 8 MiB decoded. No local path or external URL."});
properties["content_base64"] = json!({"type":"string","maxLength":BINARY_LIMIT.div_ceil(3)*4,"description":"Standard base64 of actual file bytes, at most 8 MiB decoded. No local path or external URL."});
required.push("content_base64")
}
}
@@ -96,7 +96,7 @@ pub async fn run(client: &ApiClient, call: &crate::catalog::ValidatedCall) -> Re
let encoded = call.args["content_base64"]
.as_str()
.ok_or_else(AppError::input)?;
if encoded.len() > ((BINARY_LIMIT + 2) / 3) * 4 {
if encoded.len() > BINARY_LIMIT.div_ceil(3) * 4 {
return Err(AppError::input());
}
let bytes = STANDARD.decode(encoded).map_err(|_| AppError::input())?;

View File

@@ -6,6 +6,20 @@ use crate::{
};
use serde_json::{Value, json};
use std::collections::BTreeMap;
fn allowed_definition(row: &Value) -> bool {
row["entity"]
.as_str()
.is_some_and(crate::catalog::household_field_target)
}
fn check_definition(entity: &str, row: &Value) -> Result<()> {
if entity == "userfields" && !allowed_definition(row) {
return Err(AppError::new(
"OUT_OF_SCOPE",
"Account and other non-household custom-field definitions are unavailable.",
));
}
Ok(())
}
pub async fn get_record(client: &ApiClient, entity: &str, id: &Value) -> Result<Value> {
let mut r = ApiRequest::get("/objects/{entity}/{id}");
r.params = BTreeMap::from([
@@ -17,7 +31,9 @@ pub async fn get_record(client: &ApiClient, entity: &str, id: &Value) -> Result<
.unwrap_or_else(|| id.to_string()),
),
]);
client.request(r).await?.json()
let row = client.request(r).await?.json()?;
check_definition(entity, &row)?;
Ok(row)
}
pub async fn run(client: &ApiClient, call: &ValidatedCall) -> Result<ToolOutput> {
let entity = call.args["entity"].as_str().ok_or_else(AppError::input)?;
@@ -26,7 +42,77 @@ pub async fn run(client: &ApiClient, call: &ValidatedCall) -> Result<ToolOutput>
json!({"entity":entity,"definition":entities()[entity],"notes":"Create using required fields; updates contain only changed fields. IDs are explicit. Product units require valid conversions; stock-unit changes require a valid Grocy conversion and use its database cascade."}),
));
}
let mut field_object_id = call.args["id"].clone();
if call.op.name.starts_with("userfields_") && entity == "stock" {
let stock = get_record(client, entity, &call.args["id"]).await?;
field_object_id = stock["stock_id"].clone();
if field_object_id.as_str().is_none_or(|s| {
s.is_empty()
|| s.len() > 128
|| s.contains(['/', '\\', '%'])
|| s.chars().any(char::is_control)
}) {
return Err(AppError::input());
}
}
if call.op.name == "userfields_update" && entity == "stock" {
let transaction_id = call.args["stock_transaction_id"]
.as_str()
.ok_or_else(AppError::input)?;
let mut r = ApiRequest::get("/stock/transactions/{transactionId}");
r.params
.insert("transactionId".into(), transaction_id.into());
let rows = client.request(r).await?.json()?;
let bookings = rows
.as_array()
.ok_or_else(AppError::input)?
.iter()
.filter(|r| {
["purchase", "inventory-correction", "stock-edit-new"]
.contains(&r["transaction_type"].as_str().unwrap_or(""))
})
.collect::<Vec<_>>();
if bookings.is_empty()
|| bookings.iter().any(|r| {
r["stock_id"] != field_object_id || r["undone"].as_i64().is_some_and(|n| n != 0)
})
{
return Err(AppError::new(
"STOCK_TRANSACTION_MISMATCH",
"Choose an active purchase/inventory/entry-edit transaction belonging only to this stock batch.",
));
}
}
let target_entity = if call.op.name.starts_with("userfields_") && entity == "userobjects" {
let object = get_record(client, entity, &call.args["id"]).await?;
let definition = get_record(client, "userentities", &object["userentity_id"]).await?;
let name = definition["name"].as_str().ok_or_else(AppError::input)?;
if name.contains(['/', '\\', '%']) || name.chars().any(char::is_control) {
return Err(AppError::input());
}
format!("userentity-{name}")
} else {
entity.to_owned()
};
if call.op.mutates {
if entity == "userfields"
&& let Some(target) = call.args["fields"].get("entity").and_then(Value::as_str)
&& let Some(name) = target.strip_prefix("userentity-")
{
let mut definitions = ApiRequest::get("/objects/{entity}");
definitions
.params
.insert("entity".into(), "userentities".into());
let rows = client.request(definitions).await?.json()?;
if !rows
.as_array()
.ok_or_else(AppError::input)?
.iter()
.any(|r| r["name"].as_str() == Some(name))
{
return Err(AppError::input());
}
}
if call.op.method != HttpMethod::Post {
get_record(client, entity, &call.args["id"]).await?;
}
@@ -38,10 +124,10 @@ pub async fn run(client: &ApiClient, call: &ValidatedCall) -> Result<ToolOutput>
("qu_id_consume", "quantity_units"),
("qu_id_price", "quantity_units"),
] {
if let Some(id) = call.args["fields"].get(key) {
if id.as_u64().is_some_and(|n| n > 0) {
get_record(client, target, id).await?;
}
if let Some(id) = call.args["fields"].get(key)
&& id.as_u64().is_some_and(|n| n > 0)
{
get_record(client, target, id).await?;
}
}
if call.op.method == HttpMethod::Put && call.args["fields"].get("qu_id_stock").is_some()
@@ -76,7 +162,8 @@ pub async fn run(client: &ApiClient, call: &ValidatedCall) -> Result<ToolOutput>
.ok_or_else(AppError::input)?
.iter()
.any(|r| {
r["entity"].as_str() == Some(entity) && r["name"].as_str() == Some(key)
r["entity"].as_str() == Some(&target_entity)
&& r["name"].as_str() == Some(key)
})
{
return Err(AppError::input());
@@ -95,6 +182,25 @@ pub async fn run(client: &ApiClient, call: &ValidatedCall) -> Result<ToolOutput>
.unwrap_or_else(|| v.to_string()),
);
}
if call.op.name.starts_with("userfields_") {
request
.params
.insert("entity".into(), target_entity.clone());
request.params.insert(
"id".into(),
if entity == "stock" && call.op.name == "userfields_update" {
call.args["stock_transaction_id"]
.as_str()
.unwrap()
.to_owned()
} else {
field_object_id
.as_str()
.map(str::to_owned)
.unwrap_or_else(|| field_object_id.to_string())
},
);
}
if let Some(fields) = call.args.get("fields") {
request.body = RequestBody::Json(fields.clone());
}
@@ -121,6 +227,9 @@ pub async fn run(client: &ApiClient, call: &ValidatedCall) -> Result<ToolOutput>
.clone();
let more = rows.len() > call.page.limit;
rows.truncate(call.page.limit);
if entity == "userfields" {
rows.retain(allowed_definition);
}
return Ok(ToolOutput::data(
json!({"items":rows,"has_more":more,"next_offset":if more{Some(call.page.offset+call.page.limit)}else{None}}),
));
@@ -131,7 +240,10 @@ pub async fn run(client: &ApiClient, call: &ValidatedCall) -> Result<ToolOutput>
.as_array()
.ok_or_else(AppError::input)?
.iter()
.filter(|r| r["name"].as_str() == Some(name))
.filter(|r| {
r["name"].as_str() == Some(name)
&& (entity != "userfields" || allowed_definition(r))
})
.cloned()
.collect::<Vec<_>>();
return Ok(ToolOutput::data(
@@ -139,6 +251,9 @@ pub async fn run(client: &ApiClient, call: &ValidatedCall) -> Result<ToolOutput>
));
}
if !call.op.mutates {
if call.op.name == "records_get" {
check_definition(entity, &receipt)?;
}
return Ok(ToolOutput::data(receipt));
}
let target = json!({"entity":entity,"id":call.args.get("id").or_else(||receipt.get("created_object_id")),"operation":call.op.name});
@@ -156,8 +271,14 @@ pub async fn run(client: &ApiClient, call: &ValidatedCall) -> Result<ToolOutput>
let result = if call.op.name == "userfields_update" {
let mut r = ApiRequest::get("/userfields/{entity}/{id}");
r.params = BTreeMap::from([
("entity".into(), entity.into()),
("id".into(), id.to_string()),
("entity".into(), target_entity.clone()),
(
"id".into(),
field_object_id
.as_str()
.map(str::to_owned)
.unwrap_or_else(|| field_object_id.to_string()),
),
]);
match client.request(r).await {
Ok(r) => r.json(),