663 lines
22 KiB
Rust
663 lines
22 KiB
Rust
//! Reviewable full-configuration edits. Approval rechecks versions in its transaction.
|
|
//! Filing checks public values. Approval checks current secrets and preserves operation order.
|
|
use super::{
|
|
audit,
|
|
error::{Error, Result},
|
|
items::{self, Item},
|
|
seal,
|
|
types::Caller,
|
|
};
|
|
use rusqlite::{Connection, OptionalExtension, params};
|
|
/// Explicit public operations. No operation accepts a secret field value.
|
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
|
#[serde(tag = "op", rename_all = "snake_case")]
|
|
pub enum Operation {
|
|
CreateItem {
|
|
config: Item,
|
|
},
|
|
GenerateEs256Item {
|
|
config: Item,
|
|
},
|
|
SetName {
|
|
name: String,
|
|
},
|
|
SetRules {
|
|
rules: Vec<items::Rule>,
|
|
},
|
|
SetFreeUse {
|
|
enabled: bool,
|
|
},
|
|
SetValidateJs {
|
|
value: Option<String>,
|
|
},
|
|
SetTransformJs {
|
|
value: String,
|
|
},
|
|
AddField {
|
|
field: items::Field,
|
|
},
|
|
SetFieldValue {
|
|
field: String,
|
|
value: String,
|
|
},
|
|
SetFieldType {
|
|
field: String,
|
|
field_type: super::types::FieldType,
|
|
key_type: Option<String>,
|
|
},
|
|
RenameField {
|
|
field: String,
|
|
name: String,
|
|
},
|
|
RemoveField {
|
|
field: String,
|
|
},
|
|
DeleteItem,
|
|
ItemRevert {
|
|
version: i64,
|
|
},
|
|
GrantCreate {
|
|
grant: super::grants::Grant,
|
|
},
|
|
GrantRevoke {
|
|
target: String,
|
|
},
|
|
}
|
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
|
pub struct OperationRequest {
|
|
pub item: Option<String>,
|
|
pub base_version: Option<i64>,
|
|
pub change: Vec<Operation>,
|
|
}
|
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
|
pub struct Edit {
|
|
pub item: String,
|
|
pub base_version: i64,
|
|
pub config: Item,
|
|
}
|
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
|
pub struct Diff {
|
|
pub old: Item,
|
|
pub new: Item,
|
|
pub discards_values: bool,
|
|
#[serde(default)]
|
|
pub overlapping_items: Vec<String>,
|
|
#[serde(default)]
|
|
pub stale: bool,
|
|
}
|
|
fn check_secret(
|
|
db: &Connection,
|
|
master: &[u8; 32],
|
|
item: &str,
|
|
field: &items::Field,
|
|
) -> Result<()> {
|
|
let cipher: Option<Vec<u8>> = db
|
|
.query_row(
|
|
"SELECT ciphertext FROM fields WHERE item=?1 AND id=?2",
|
|
params![item, field.field_id],
|
|
|r| r.get(0),
|
|
)
|
|
.optional()?;
|
|
if let Some(cipher) = cipher {
|
|
let value = seal::open(master, &format!("{item}|{}", field.field_id), &cipher)?;
|
|
super::types::validate(
|
|
&field.field_type,
|
|
field.key_type.as_deref(),
|
|
false,
|
|
Some(std::str::from_utf8(&value).map_err(|_| Error("invalid_value"))?),
|
|
)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
pub(crate) fn plan(db: &Connection, master: Option<&[u8; 32]>, edit: &Edit) -> Result<Diff> {
|
|
let old = items::load(db, &edit.item)?;
|
|
if old.version != edit.base_version {
|
|
return Err(Error("stale"));
|
|
}
|
|
let mut new = edit.config.clone();
|
|
new.id = old.id.clone();
|
|
new.version = old
|
|
.version
|
|
.checked_add(1)
|
|
.ok_or(Error("version_exhausted"))?;
|
|
items::validate(&new)?;
|
|
items::unique_name(db, &new.name, &old.id)?;
|
|
for f in &mut new.fields {
|
|
if let Some(prior) = old.fields.iter().find(|p| p.field_id == f.field_id) {
|
|
if prior.public != f.public {
|
|
return Err(Error("immutable_visibility"));
|
|
}
|
|
if f.public {
|
|
f.value = f.value.clone().or_else(|| prior.value.clone());
|
|
f.needs_value = f.value.is_none();
|
|
} else {
|
|
f.needs_value = prior.needs_value;
|
|
if let Some(master) = master
|
|
&& (f.field_type != prior.field_type || f.key_type != prior.key_type)
|
|
{
|
|
check_secret(db, master, &old.id, f)?;
|
|
}
|
|
}
|
|
} else {
|
|
if !f.field_id.is_empty() {
|
|
return Err(Error("field_missing"));
|
|
}
|
|
f.needs_value = f.value.is_none();
|
|
}
|
|
}
|
|
items::validate(&new)?;
|
|
let discards_values = old
|
|
.fields
|
|
.iter()
|
|
.any(|f| !f.needs_value && !new.fields.iter().any(|n| n.field_id == f.field_id));
|
|
Ok(Diff {
|
|
overlapping_items: items::overlaps(db, &new)?,
|
|
old,
|
|
new,
|
|
discards_values,
|
|
stale: false,
|
|
})
|
|
}
|
|
pub(crate) fn apply(
|
|
db: &Connection,
|
|
master: &[u8; 32],
|
|
edit: &Edit,
|
|
caller: &Caller,
|
|
approver: Option<&str>,
|
|
) -> Result<()> {
|
|
let mut diff = plan(db, Some(master), edit)?;
|
|
db.execute(
|
|
"INSERT INTO versions(item,created,config) VALUES(?1,unixepoch(),?2)",
|
|
params![edit.item, serde_json::to_string(&diff.old)?],
|
|
)?;
|
|
for old in &diff.old.fields {
|
|
if !diff.new.fields.iter().any(|f| f.field_id == old.field_id) {
|
|
db.execute(
|
|
"DELETE FROM fields WHERE item=?1 AND id=?2",
|
|
params![edit.item, old.field_id],
|
|
)?;
|
|
}
|
|
}
|
|
for f in &mut diff.new.fields {
|
|
if f.field_id.is_empty() {
|
|
f.field_id = seal::identity();
|
|
}
|
|
}
|
|
items::save(db, &diff.new)?;
|
|
audit::record(db, "item.edit", caller, Some(&edit.item), approver)
|
|
}
|
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
|
pub struct Revert {
|
|
pub item: String,
|
|
pub base_version: i64,
|
|
pub version: i64,
|
|
}
|
|
pub(crate) fn revert_revision(db: &Connection, item: &str) -> Result<i64> {
|
|
if let Ok(item) = items::load(db, item) {
|
|
return Ok(item.version);
|
|
}
|
|
let latest: Option<i64> = db.query_row(
|
|
"SELECT max(json_extract(config,'$.version')) FROM versions WHERE item=?",
|
|
[item],
|
|
|r| r.get(0),
|
|
)?;
|
|
latest
|
|
.ok_or(Error("item_deleted"))?
|
|
.checked_add(1)
|
|
.ok_or(Error("version_exhausted"))
|
|
}
|
|
pub(crate) fn plan_revert(db: &Connection, master: Option<&[u8; 32]>, r: &Revert) -> Result<Diff> {
|
|
if revert_revision(db, &r.item)? != r.base_version {
|
|
return Err(Error("stale"));
|
|
}
|
|
let saved:String=db.query_row("SELECT config FROM versions WHERE item=?1 AND json_extract(config,'$.version')=?2 ORDER BY rowid DESC LIMIT 1",params![r.item,r.version],|row|row.get(0))?;
|
|
let mut new: Item = serde_json::from_str(&saved)?;
|
|
let current = items::load(db, &r.item).ok();
|
|
for f in &mut new.fields {
|
|
let prior = current
|
|
.as_ref()
|
|
.and_then(|i| i.fields.iter().find(|p| p.field_id == f.field_id));
|
|
if let Some(prior) = prior {
|
|
if prior.public != f.public {
|
|
return Err(Error("immutable_visibility"));
|
|
}
|
|
f.needs_value = prior.needs_value;
|
|
if f.public {
|
|
f.value = prior.value.clone();
|
|
} else if let Some(master) = master {
|
|
check_secret(db, master, &r.item, f)?;
|
|
}
|
|
} else {
|
|
f.needs_value = f.value.is_none();
|
|
}
|
|
}
|
|
items::validate(&new)?;
|
|
items::unique_name(db, &new.name, &new.id)?;
|
|
new.version = r
|
|
.base_version
|
|
.checked_add(1)
|
|
.ok_or(Error("version_exhausted"))?;
|
|
let old = current.unwrap_or_else(|| {
|
|
let mut old = new.clone();
|
|
old.version = r.base_version;
|
|
old.fields.clear();
|
|
old
|
|
});
|
|
let discards_values = old
|
|
.fields
|
|
.iter()
|
|
.any(|f| !f.needs_value && !new.fields.iter().any(|n| n.field_id == f.field_id));
|
|
Ok(Diff {
|
|
overlapping_items: items::overlaps(db, &new)?,
|
|
old,
|
|
new,
|
|
discards_values,
|
|
stale: false,
|
|
})
|
|
}
|
|
pub(crate) fn apply_revert(
|
|
db: &Connection,
|
|
master: &[u8; 32],
|
|
r: &Revert,
|
|
caller: &Caller,
|
|
approver: Option<&str>,
|
|
) -> Result<()> {
|
|
let diff = plan_revert(db, Some(master), r)?;
|
|
if items::load(db, &r.item).is_ok() {
|
|
db.execute(
|
|
"INSERT INTO versions(item,created,config) VALUES(?1,unixepoch(),?2)",
|
|
params![r.item, serde_json::to_string(&diff.old)?],
|
|
)?;
|
|
items::save(db, &diff.new)?;
|
|
} else {
|
|
db.execute(
|
|
"INSERT INTO items VALUES(?1,?2,?3)",
|
|
params![r.item, diff.new.name, serde_json::to_string(&diff.new)?],
|
|
)?;
|
|
}
|
|
for f in &diff.old.fields {
|
|
if !diff.new.fields.iter().any(|n| n.field_id == f.field_id) {
|
|
db.execute(
|
|
"DELETE FROM fields WHERE item=?1 AND id=?2",
|
|
params![r.item, f.field_id],
|
|
)?;
|
|
}
|
|
}
|
|
audit::record(db, "item.revert", caller, Some(&r.item), approver)
|
|
}
|
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
|
pub struct Delete {
|
|
pub item: String,
|
|
pub base_version: i64,
|
|
}
|
|
pub(crate) fn apply_delete(
|
|
db: &Connection,
|
|
delete: &Delete,
|
|
caller: &Caller,
|
|
approver: Option<&str>,
|
|
) -> Result<()> {
|
|
let item = items::load(db, &delete.item)?;
|
|
if item.version != delete.base_version {
|
|
return Err(Error("stale"));
|
|
}
|
|
db.execute(
|
|
"INSERT INTO versions(item,created,config) VALUES(?1,unixepoch(),?2)",
|
|
params![item.id, serde_json::to_string(&item)?],
|
|
)?;
|
|
db.execute("DELETE FROM items WHERE id=?", [&item.id])?;
|
|
db.execute("UPDATE grants SET revoked=1 WHERE EXISTS(SELECT 1 FROM json_each(grants.items) WHERE value=?)", [&item.id])?;
|
|
audit::record(db, "item.delete", caller, Some(&item.id), approver)
|
|
}
|
|
pub(crate) fn apply_changes(
|
|
mut config: Item,
|
|
change: &[Operation],
|
|
start: usize,
|
|
secrets: Option<(&Connection, &[u8; 32])>,
|
|
) -> Result<Item> {
|
|
for operation in &change[start..] {
|
|
let field = |id: &str| {
|
|
if id.is_empty() {
|
|
return Err(Error("field_missing"));
|
|
}
|
|
config
|
|
.fields
|
|
.iter()
|
|
.position(|f| f.field_id == id)
|
|
.ok_or(Error("field_missing"))
|
|
};
|
|
match operation {
|
|
Operation::SetName { name } => config.name = name.clone(),
|
|
Operation::SetRules { rules } => config.rules = rules.clone(),
|
|
Operation::SetFreeUse { enabled } => config.free_use = *enabled,
|
|
Operation::SetValidateJs { value } => config.validate_js = value.clone(),
|
|
Operation::SetTransformJs { value } => config.transform_js = value.clone(),
|
|
Operation::AddField { field } => {
|
|
let mut field = field.clone();
|
|
if !field.field_id.is_empty() {
|
|
return Err(Error("invalid_params"));
|
|
}
|
|
if !field.public && field.value.is_some() {
|
|
return Err(Error("secret_field"));
|
|
}
|
|
super::types::validate(
|
|
&field.field_type,
|
|
field.key_type.as_deref(),
|
|
field.public,
|
|
field.value.as_deref(),
|
|
)?;
|
|
field.needs_value = field.value.is_none();
|
|
config.fields.push(field);
|
|
}
|
|
Operation::SetFieldValue { field: id, value } => {
|
|
let n = field(id)?;
|
|
if !config.fields[n].public {
|
|
return Err(Error("secret_field"));
|
|
}
|
|
super::types::validate(
|
|
&config.fields[n].field_type,
|
|
config.fields[n].key_type.as_deref(),
|
|
true,
|
|
Some(value),
|
|
)?;
|
|
config.fields[n].value = Some(value.clone());
|
|
config.fields[n].needs_value = false;
|
|
}
|
|
Operation::SetFieldType {
|
|
field: id,
|
|
field_type,
|
|
key_type,
|
|
} => {
|
|
let n = field(id)?;
|
|
config.fields[n].field_type = field_type.clone();
|
|
config.fields[n].key_type = key_type.clone();
|
|
let f = &config.fields[n];
|
|
super::types::validate(
|
|
&f.field_type,
|
|
f.key_type.as_deref(),
|
|
f.public,
|
|
f.value.as_deref(),
|
|
)?;
|
|
if !f.public
|
|
&& let Some((db, master)) = secrets
|
|
{
|
|
check_secret(db, master, &config.id, f)?;
|
|
}
|
|
}
|
|
Operation::RenameField { field: id, name } => {
|
|
let n = field(id)?;
|
|
config.fields[n].name = name.clone();
|
|
}
|
|
Operation::RemoveField { field: id } => {
|
|
let n = field(id)?;
|
|
config.fields.remove(n);
|
|
}
|
|
_ => return Err(Error("invalid_params")),
|
|
}
|
|
}
|
|
Ok(config)
|
|
}
|
|
impl super::Cred {
|
|
pub fn file_operation_request(
|
|
&self,
|
|
caller: &Caller,
|
|
key: &str,
|
|
request: OperationRequest,
|
|
) -> Result<String> {
|
|
if request.change.is_empty() {
|
|
return Err(Error("invalid_params"));
|
|
}
|
|
if request.change.len() == 1 {
|
|
match &request.change[0] {
|
|
Operation::DeleteItem => {
|
|
return self.file_delete_request(
|
|
caller,
|
|
key,
|
|
Delete {
|
|
item: request.item.clone().ok_or(Error("invalid_params"))?,
|
|
base_version: request.base_version.ok_or(Error("invalid_params"))?,
|
|
},
|
|
);
|
|
}
|
|
Operation::ItemRevert { version } => {
|
|
return self.file_revert_request(
|
|
caller,
|
|
key,
|
|
request.item.as_deref().ok_or(Error("invalid_params"))?,
|
|
request.base_version.ok_or(Error("invalid_params"))?,
|
|
*version,
|
|
);
|
|
}
|
|
Operation::GrantCreate { grant } => {
|
|
let mut grant = grant.clone();
|
|
grant.id.clear();
|
|
grant.uses = 0;
|
|
grant.revoked = false;
|
|
grant.items.sort();
|
|
grant.items.dedup();
|
|
let payload = serde_json::json!({"grant_create":grant});
|
|
if let Some(id) = self.replay(caller, key, &payload)? {
|
|
return Ok(id);
|
|
}
|
|
super::grants::validate(&self.access()?.db, &grant)?;
|
|
return self.file_payload(
|
|
caller,
|
|
key,
|
|
serde_json::json!({"grant_create":grant}),
|
|
);
|
|
}
|
|
Operation::GrantRevoke { target } => {
|
|
self.grant_status(target)?;
|
|
return self.file_payload(
|
|
caller,
|
|
key,
|
|
serde_json::json!({"grant_revoke":target}),
|
|
);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
let intent = serde_json::json!({"requested":request});
|
|
if let Some(id) = self.replay(caller, key, &intent)? {
|
|
return Ok(id);
|
|
}
|
|
let mut create = false;
|
|
let mut generate = false;
|
|
let config = if let Some(id) = &request.item {
|
|
self.item_get(id)?
|
|
} else {
|
|
create = true;
|
|
match &request.change[0] {
|
|
Operation::CreateItem { config } => config.clone(),
|
|
Operation::GenerateEs256Item { config } => {
|
|
generate = true;
|
|
config.clone()
|
|
}
|
|
_ => return Err(Error("invalid_params")),
|
|
}
|
|
};
|
|
if create {
|
|
items::validate(&config)?;
|
|
}
|
|
let config = apply_changes(config, &request.change, usize::from(create), None)?;
|
|
if create {
|
|
if request.base_version.is_some() {
|
|
return Err(Error("invalid_params"));
|
|
}
|
|
items::validate(&config)?;
|
|
if generate
|
|
&& !config.fields.iter().any(|f| {
|
|
!f.public
|
|
&& f.field_type == super::types::FieldType::SshPrivateKey
|
|
&& f.key_type.as_deref() == Some("es256")
|
|
})
|
|
{
|
|
return Err(Error("invalid_params"));
|
|
}
|
|
let overlapping_items = {
|
|
let s = self.access()?;
|
|
items::unique_name(&s.db, &config.name, "")?;
|
|
let mut proposed = config.clone();
|
|
proposed.id.clear();
|
|
items::overlaps(&s.db, &proposed)?
|
|
};
|
|
return self.file_payload(
|
|
caller,
|
|
key,
|
|
serde_json::json!({"create":config,"generate_es256":generate,"requested":request,"overlapping_items":overlapping_items}),
|
|
);
|
|
}
|
|
let edit = Edit {
|
|
item: request.item.clone().ok_or(Error("invalid_params"))?,
|
|
base_version: request.base_version.ok_or(Error("invalid_params"))?,
|
|
config,
|
|
};
|
|
let diff = {
|
|
let s = self.access()?;
|
|
plan(&s.db, None, &edit)?
|
|
};
|
|
self.file_payload(
|
|
caller,
|
|
key,
|
|
serde_json::json!({"edit":edit,"diff":diff,"requested":request}),
|
|
)
|
|
}
|
|
pub fn file_delete_request(
|
|
&self,
|
|
caller: &Caller,
|
|
key: &str,
|
|
delete: Delete,
|
|
) -> Result<String> {
|
|
if let Some(id) = self.replay(caller, key, &serde_json::json!({"delete":delete}))? {
|
|
return Ok(id);
|
|
}
|
|
let item = self.item_get(&delete.item)?;
|
|
if item.version != delete.base_version {
|
|
return Err(Error("stale"));
|
|
}
|
|
self.file_payload(caller, key, serde_json::json!({"delete":delete,"old":item}))
|
|
}
|
|
/// Revert public configuration. Removed secrets remain absent.
|
|
pub fn file_revert_request(
|
|
&self,
|
|
caller: &Caller,
|
|
key: &str,
|
|
item: &str,
|
|
base_version: i64,
|
|
version: i64,
|
|
) -> Result<String> {
|
|
let revert = Revert {
|
|
item: item.into(),
|
|
base_version,
|
|
version,
|
|
};
|
|
if let Some(id) = self.replay(caller, key, &serde_json::json!({"revert":revert}))? {
|
|
return Ok(id);
|
|
}
|
|
let diff = {
|
|
let s = self.access()?;
|
|
plan_revert(&s.db, None, &revert)?
|
|
};
|
|
self.file_payload(
|
|
caller,
|
|
key,
|
|
serde_json::json!({"revert":revert,"diff":diff}),
|
|
)
|
|
}
|
|
pub fn file_edit_request(&self, caller: &Caller, key: &str, edit: Edit) -> Result<String> {
|
|
if let Some(id) = self.replay(caller, key, &serde_json::json!({"edit":edit}))? {
|
|
return Ok(id);
|
|
}
|
|
let diff = {
|
|
let s = self.access()?;
|
|
plan(&s.db, None, &edit)?
|
|
};
|
|
self.file_payload(caller, key, serde_json::json!({"edit":edit,"diff":diff}))
|
|
}
|
|
/// Replace a pending edit's base version after the UI reviews the current item.
|
|
pub fn resolve_stale(
|
|
&self,
|
|
id: &str,
|
|
base_version: i64,
|
|
caller: &Caller,
|
|
proof: impl Into<super::webauthn::Proof>,
|
|
) -> Result<()> {
|
|
let mut s = self.access()?;
|
|
let tx =
|
|
s.db.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
|
|
let binding = super::webauthn::Binding::mutation(
|
|
super::webauthn::Mutation::ResolveStale,
|
|
&serde_json::json!({"request_id":id,"base_version":base_version}),
|
|
)?;
|
|
let approver = proof.into().check(&tx, &binding)?;
|
|
let (payload, state): (String, String) =
|
|
tx.query_row("SELECT payload,state FROM requests WHERE id=?", [id], |r| {
|
|
Ok((r.get(0)?, r.get(1)?))
|
|
})?;
|
|
if state != "pending" {
|
|
return Err(Error("already_decided"));
|
|
}
|
|
let mut payload: serde_json::Value = serde_json::from_str(&payload)?;
|
|
if let Some(revert) = payload.get("revert") {
|
|
let mut revert: Revert = serde_json::from_value(revert.clone())?;
|
|
revert.base_version = base_version;
|
|
let diff = plan_revert(&tx, None, &revert)?;
|
|
payload["revert"] = serde_json::to_value(revert)?;
|
|
payload["diff"] = serde_json::to_value(diff)?;
|
|
} else {
|
|
let mut edit: Edit =
|
|
serde_json::from_value(payload.get("edit").ok_or(Error("not_edit"))?.clone())?;
|
|
edit.base_version = base_version;
|
|
if let Some(requested) = payload.get("requested") {
|
|
let requested: OperationRequest = serde_json::from_value(requested.clone())?;
|
|
edit.config =
|
|
apply_changes(items::load(&tx, &edit.item)?, &requested.change, 0, None)?;
|
|
}
|
|
let diff = plan(&tx, None, &edit)?;
|
|
payload["edit"] = serde_json::to_value(edit)?;
|
|
payload["diff"] = serde_json::to_value(diff)?;
|
|
}
|
|
let encoded = serde_json::to_string(&payload)?;
|
|
tx.execute(
|
|
"UPDATE requests SET payload=?2 WHERE id=?1",
|
|
params![id, encoded],
|
|
)?;
|
|
audit::record(
|
|
&tx,
|
|
"request.resolve_stale",
|
|
caller,
|
|
payload
|
|
.get("edit")
|
|
.or_else(|| payload.get("revert"))
|
|
.and_then(|p| p.get("item"))
|
|
.and_then(|v| v.as_str()),
|
|
approver.as_deref(),
|
|
)?;
|
|
tx.execute(
|
|
"UPDATE audit SET payload_sha256=?1 WHERE id=?2",
|
|
params![
|
|
super::webauthn::digest(encoded.as_bytes()),
|
|
tx.last_insert_rowid()
|
|
],
|
|
)?;
|
|
tx.commit()?;
|
|
Ok(())
|
|
}
|
|
pub fn edit_diff(&self, id: &str) -> Result<Diff> {
|
|
let payload = self.request_diff(id)?;
|
|
let mut diff: Diff =
|
|
serde_json::from_value(payload.get("diff").ok_or(Error("not_edit"))?.clone())?;
|
|
if let Some(revert) = payload.get("revert") {
|
|
let revert: Revert = serde_json::from_value(revert.clone())?;
|
|
diff.stale = revert_revision(&self.access()?.db, &revert.item)
|
|
.map_or(true, |v| v != revert.base_version);
|
|
} else {
|
|
let edit: Edit =
|
|
serde_json::from_value(payload.get("edit").ok_or(Error("not_edit"))?.clone())?;
|
|
diff.stale = self
|
|
.item_get(&edit.item)
|
|
.map_or(true, |i| i.version != edit.base_version);
|
|
}
|
|
Ok(diff)
|
|
}
|
|
}
|