| 1 | //! Artifacts over REST and MCP: a workspace's docs, slides, designs and |
| 2 | //! dashboards (Artifacts mode), at |
| 3 | //! `/workspaces/{workspace}/artifacts` and as the `artifact` MCP tool. |
| 4 | //! Code calls them folios; people, URLs, the tool and the scopes say |
| 5 | //! "artifact". Workflow runs' artifacts are something else (run_artifacts.rs). |
| 6 | //! |
| 7 | //! The artifacts service (`services/artifacts`, the `ARTIFACTS` binding) decides who may |
| 8 | //! do what with each one, from the person's own role on it: this checks |
| 9 | //! the input, names people for the service (a username becomes |
| 10 | //! `user:<id>`), and gives each answer its public shape, in snake_case. |
| 11 | //! The token's `artifacts:*` scope is checked before anything runs |
| 12 | //! (audit.rs). A workspace's own token is refused: an artifact always has |
| 13 | //! a person as its owner. |
| 14 | |
| 15 | use g1t_contracts::datasets::DatasetQuery; |
| 16 | use g1t_contracts::folios::*; |
| 17 | use g1t_contracts::identity::UsernameArgs; |
| 18 | use g1t_contracts::{FailureCode, Outcome, PrincipalKind, User, Viewer}; |
| 19 | use serde::Serialize; |
| 20 | use serde::de::DeserializeOwned; |
| 21 | use serde_json::{Map, Value, json}; |
| 22 | use worker::Result; |
| 23 | |
| 24 | use crate::operations::Services; |
| 25 | |
| 26 | /// One operation on artifacts. |
| 27 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 28 | pub enum FoliosOp { |
| 29 | List, |
| 30 | Search, |
| 31 | Get, |
| 32 | GetContent, |
| 33 | ListVersions, |
| 34 | GetAccess, |
| 35 | ListTemplates, |
| 36 | ListSpaces, |
| 37 | QueryDataset, |
| 38 | Create, |
| 39 | Update, |
| 40 | Edit, |
| 41 | Trash, |
| 42 | Restore, |
| 43 | RestoreVersion, |
| 44 | SetAccess, |
| 45 | Purge, |
| 46 | } |
| 47 | |
| 48 | impl FoliosOp { |
| 49 | /// Every one: `Op::ALL` lists each as `Op::Folios(…)`, which a test |
| 50 | /// checks against this. |
| 51 | #[cfg(test)] |
| 52 | pub const ALL: [FoliosOp; 17] = [ |
| 53 | FoliosOp::List, |
| 54 | FoliosOp::Search, |
| 55 | FoliosOp::Get, |
| 56 | FoliosOp::GetContent, |
| 57 | FoliosOp::ListVersions, |
| 58 | FoliosOp::GetAccess, |
| 59 | FoliosOp::ListTemplates, |
| 60 | FoliosOp::ListSpaces, |
| 61 | FoliosOp::QueryDataset, |
| 62 | FoliosOp::Create, |
| 63 | FoliosOp::Update, |
| 64 | FoliosOp::Edit, |
| 65 | FoliosOp::Trash, |
| 66 | FoliosOp::Restore, |
| 67 | FoliosOp::RestoreVersion, |
| 68 | FoliosOp::SetAccess, |
| 69 | FoliosOp::Purge, |
| 70 | ]; |
| 71 | |
| 72 | /// Its operation id. `workspace_artifact`, because GitHub's names for |
| 73 | /// workflow runs' artifacts (`list_artifacts`, `get_artifact`) are taken. |
| 74 | pub fn name(self) -> &'static str { |
| 75 | match self { |
| 76 | FoliosOp::List => "list_workspace_artifacts", |
| 77 | FoliosOp::Search => "search_workspace_artifacts", |
| 78 | FoliosOp::Get => "get_workspace_artifact", |
| 79 | FoliosOp::GetContent => "get_workspace_artifact_content", |
| 80 | FoliosOp::ListVersions => "list_workspace_artifact_versions", |
| 81 | FoliosOp::GetAccess => "get_workspace_artifact_access", |
| 82 | FoliosOp::ListTemplates => "list_workspace_artifact_templates", |
| 83 | FoliosOp::ListSpaces => "list_workspace_artifact_spaces", |
| 84 | FoliosOp::QueryDataset => "query_workspace_dataset", |
| 85 | FoliosOp::Create => "create_workspace_artifact", |
| 86 | FoliosOp::Update => "update_workspace_artifact", |
| 87 | FoliosOp::Edit => "edit_workspace_artifact", |
| 88 | FoliosOp::Trash => "trash_workspace_artifact", |
| 89 | FoliosOp::Restore => "restore_workspace_artifact", |
| 90 | FoliosOp::RestoreVersion => "restore_workspace_artifact_version", |
| 91 | FoliosOp::SetAccess => "set_workspace_artifact_access", |
| 92 | FoliosOp::Purge => "purge_workspace_artifact", |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | /// For the API reference. |
| 97 | pub fn title(self) -> &'static str { |
| 98 | match self { |
| 99 | FoliosOp::List => "List a workspace's artifacts", |
| 100 | FoliosOp::Search => "Search a workspace's artifacts", |
| 101 | FoliosOp::Get => "Get an artifact", |
| 102 | FoliosOp::GetContent => "Get an artifact's content", |
| 103 | FoliosOp::ListVersions => "List an artifact's versions", |
| 104 | FoliosOp::GetAccess => "Get who can open an artifact", |
| 105 | FoliosOp::ListTemplates => "List artifact templates", |
| 106 | FoliosOp::ListSpaces => "List artifact spaces", |
| 107 | FoliosOp::QueryDataset => "Query a dataset", |
| 108 | FoliosOp::Create => "Create an artifact", |
| 109 | FoliosOp::Update => "Update an artifact", |
| 110 | FoliosOp::Edit => "Edit an artifact's content", |
| 111 | FoliosOp::Trash => "Move an artifact to the trash", |
| 112 | FoliosOp::Restore => "Restore an artifact from the trash", |
| 113 | FoliosOp::RestoreVersion => "Restore an artifact version", |
| 114 | FoliosOp::SetAccess => "Share an artifact", |
| 115 | FoliosOp::Purge => "Delete an artifact for good", |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | pub fn description(self) -> &'static str { |
| 120 | match self { |
| 121 | FoliosOp::List => "List the artifacts (docs, slides, designs and dashboards) in a workspace that you can open, most recently edited first: each with its id (fol_…), kind, title, icon, space (null in its owner's Private), parent_id, owner, your role (viewer_role: view, comment, edit or manage), general_access, private, excerpt, edited_at and html_url. tab is all (the default), yours (you own them) or shared (shared with you). Narrow with kind, space (its slug or id), project (owner/name), q (words or meaning) and limit (at most 100); next_cursor pages on. With state trashed, the artifacts in the trash you can restore instead. Not workflow runs' artifacts.", |
| 122 | FoliosOp::Search => "Search the artifacts in a workspace that you can open, by words and by meaning: each hit is the artifact with the passage that matched (snippet, and the heading it is under), its space_name and edited_at. Narrow with kind, space, project and limit (at most 50). Only what you can read now is found.", |
| 123 | FoliosOp::Get => "Get one artifact by its id (fol_…) or its address (`/acme/-/artifacts/q4-roadmap-fol_…`): its kind, title, space, parent_id, owner, who made it, your role, general_access, whether it is private, how many it is shared with, its excerpt, whether it may be out of date with the code it cites (stale), and html_url. Not found when you cannot open it, as for one that does not exist. Opening a link-shared artifact this way counts as following its link.", |
| 124 | FoliosOp::GetContent => "Get an artifact's content in the form agents read and write: for a doc, its Markdown in content, and its top-level blocks (id, type, level and Markdown) to target an edit at; with can (read, suggest, edit): what you may do to it. Slides, designs and dashboards answer that they are not here yet.", |
| 125 | FoliosOp::ListVersions => "List an artifact's saved versions, newest first: each with its id (ver_…), created_at, kind (created, edit, agent, suggestion, proposal or restore), note, and the people and agents who made it.", |
| 126 | FoliosOp::GetAccess => "Who can open an artifact and how: its owner, each person, agent and team it is shared with (principal, role, and inherited_from when the access comes from a doc it is under), general_access (none, workspace or link) with general_role, whether it follows its space or parent (inherit), agent_mode, and whether you may change it (can_share).", |
| 127 | FoliosOp::ListTemplates => "The templates an artifact can start from: the built-in ones and those saved in the workspace, each with its id, kind, name, description and body (Markdown, or a JSON spec). Narrow with kind.", |
| 128 | FoliosOp::ListSpaces => "The spaces in your Artifacts sidebar: the General space, open spaces you joined, your teams' spaces and Members-only spaces you are in. Each with its id, slug, name, kind (workspace for an open space, team or private), your role in it (viewer_role) and how many artifacts it holds you can open.", |
| 129 | FoliosOp::QueryDataset => "Run a dataset query (issues, pull requests, workflow runs, deployments, spend or agent sessions) as you, over what you can read: rows of the measure, grouped and filtered as asked, with truncated when more rows matched, and partial true when some of it was left out because you cannot read it. Answers that dashboards are not here yet until they ship.", |
| 130 | FoliosOp::Create => "Make an artifact. kind is doc (the default); slides, designs and dashboards answer that they are not here yet. It lands in space (a slug or id you can edit in), under parent_id (a doc you can edit), or, with neither, in your Private, where only you can open it. Start it from markdown, or from a template (template_id), with an optional title and icon. You own it. Returns the artifact.", |
| 131 | FoliosOp::Update => "Rename an artifact (title), change its icon (null clears it), or move it: to space (a slug or id; `private` for your Private, yours only), under parent_id (a doc), before before_id among its new siblings. Moving changes who can open it when it follows its space or parent. Takes the edit role on it, and on where it goes. Returns the artifact.", |
| 132 | FoliosOp::Edit => "Change an artifact's content. For a doc: markdown with a target: append (add to the end), document (replace it all), section (with heading: that heading and everything under it) or blocks (from_block through to_block, block ids from its content). With the edit role the change is made, as a new version; with the comment role, or suggest_only, it is filed as a suggestion its editors accept or reject. note says why; marks_current says it brings the doc up to date with the code it cites. Returns mode (applied or suggested), version_id or the suggestion, and the artifact.", |
| 133 | FoliosOp::Trash => "Move an artifact, and everything under it, to the trash: nobody can open it until it is restored, and it is deleted for good after 30 days. Takes the edit role. Returns the artifact, with trashed_at.", |
| 134 | FoliosOp::Restore => "Bring an artifact back from the trash, with what went to the trash with it, where it was (at the top of its space or your Private when the doc it was under is gone). Takes the edit role. Returns the artifact.", |
| 135 | FoliosOp::RestoreVersion => "Make an earlier version (version_id) an artifact's content again, as a new version: nothing in between is lost. Takes the edit role. Returns the new version.", |
| 136 | FoliosOp::SetAccess => "Change who can open an artifact. Share it with a person (username), a team (team: its slug) or an agent (agent: its id), or a principal from its access list, at role view, comment, edit or manage (full access), with an optional notify message; role none takes theirs away. Set general_access to none (only people invited), workspace (everyone in the workspace) or link (anyone in the workspace with the link), with general_role view, comment or edit. inherit false stops it following its space or parent; true follows again. agent_mode suggest or edit says how agents change it (null follows its space). Takes full access (manage). Returns who can open it now.", |
| 137 | FoliosOp::Purge => "Delete an artifact in the trash, and everything under it, for good: its content and versions are gone and cannot be restored. Move it to the trash first. Takes full access (manage).", |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | /// Whether it changes anything. |
| 142 | #[cfg(test)] |
| 143 | pub fn writes(self) -> bool { |
| 144 | !matches!( |
| 145 | self, |
| 146 | FoliosOp::List |
| 147 | | FoliosOp::Search |
| 148 | | FoliosOp::Get |
| 149 | | FoliosOp::GetContent |
| 150 | | FoliosOp::ListVersions |
| 151 | | FoliosOp::GetAccess |
| 152 | | FoliosOp::ListTemplates |
| 153 | | FoliosOp::ListSpaces |
| 154 | | FoliosOp::QueryDataset |
| 155 | ) |
| 156 | } |
| 157 | |
| 158 | pub fn input(self) -> Value { |
| 159 | let workspace = json!({ "type": "string", "description": "The workspace's slug, e.g. \"acme\"." }); |
| 160 | let artifact_id = json!({ "type": "string", "description": "The artifact's id (fol_…), or its address: /acme/-/artifacts/q4-roadmap-fol_…" }); |
| 161 | let kind = json!({ "type": "string", "enum": ["doc", "slides", "design", "dashboard"], "description": "doc, slides, design or dashboard." }); |
| 162 | let space = json!({ "type": "string", "description": "A space: its slug (general) or id (spc_…)." }); |
| 163 | let project = json!({ "type": "string", "description": "Only artifacts about this repository: owner/name." }); |
| 164 | let with = |mut properties: Value| { |
| 165 | properties["workspace"] = workspace.clone(); |
| 166 | properties["artifact_id"] = artifact_id.clone(); |
| 167 | properties |
| 168 | }; |
| 169 | let one = ["workspace", "artifact_id"]; |
| 170 | let (properties, required): (Value, Vec<&str>) = match self { |
| 171 | FoliosOp::List => ( |
| 172 | json!({ |
| 173 | "workspace": workspace, |
| 174 | "tab": { "type": "string", "enum": ["all", "yours", "shared"], "description": "all (the default), yours (you own them) or shared (shared with you by others)." }, |
| 175 | "kind": kind, |
| 176 | "space": space, |
| 177 | "project": project, |
| 178 | "q": { "type": "string", "description": "Only artifacts matching these words or this meaning, best first." }, |
| 179 | "state": { "type": "string", "enum": ["active", "trashed"], "description": "active (the default), or trashed: what you can restore from the trash." }, |
| 180 | "cursor": { "type": "string", "description": "next_cursor from the page before." }, |
| 181 | "limit": { "type": "integer", "minimum": 1, "maximum": 100, "description": "How many, at most 100." }, |
| 182 | }), |
| 183 | vec!["workspace"], |
| 184 | ), |
| 185 | FoliosOp::Search => ( |
| 186 | json!({ |
| 187 | "workspace": workspace, |
| 188 | "q": { "type": "string", "description": "Words or a question." }, |
| 189 | "kind": kind, |
| 190 | "space": space, |
| 191 | "project": project, |
| 192 | "limit": { "type": "integer", "minimum": 1, "maximum": 50, "description": "How many hits, at most 50." }, |
| 193 | }), |
| 194 | vec!["workspace", "q"], |
| 195 | ), |
| 196 | FoliosOp::Get | FoliosOp::GetContent | FoliosOp::ListVersions | FoliosOp::GetAccess | FoliosOp::Trash | FoliosOp::Restore | FoliosOp::Purge => { |
| 197 | (with(json!({})), one.to_vec()) |
| 198 | } |
| 199 | FoliosOp::ListTemplates => (json!({ "workspace": workspace, "kind": kind }), vec!["workspace"]), |
| 200 | FoliosOp::ListSpaces => (json!({ "workspace": workspace }), vec!["workspace"]), |
| 201 | FoliosOp::QueryDataset => ( |
| 202 | json!({ |
| 203 | "workspace": workspace, |
| 204 | "query": { |
| 205 | "type": "object", |
| 206 | "description": "dataset (issues, pull_requests, workflow_runs, deployments, spend or agent_sessions), measure ({ op: count, sum, avg, p50, p95 or rate, field where it takes one }), and optional group_by, interval (day, week or month), time, filters ([{ field, op, value }], at most 10), range (7d, 30d, 90d, or { from, to }, at most 366 days) and limit (at most 100).", |
| 207 | }, |
| 208 | }), |
| 209 | vec!["workspace", "query"], |
| 210 | ), |
| 211 | FoliosOp::Create => ( |
| 212 | json!({ |
| 213 | "workspace": workspace, |
| 214 | "kind": kind, |
| 215 | "title": { "type": "string", "description": "At most 200 characters. Left out: the template's, or Untitled." }, |
| 216 | "icon": { "type": "string", "description": "An emoji." }, |
| 217 | "space": { "type": "string", "description": "A space you can edit in: its slug or id. Left out, with no parent_id: your Private." }, |
| 218 | "parent_id": { "type": "string", "description": "A doc to put it under, which you can edit: its id. Its space is the doc's." }, |
| 219 | "markdown": { "type": "string", "description": "What a doc starts with, in Markdown." }, |
| 220 | "template_id": { "type": "string", "description": "A template to start from (list_workspace_artifact_templates), instead of markdown." }, |
| 221 | }), |
| 222 | vec!["workspace"], |
| 223 | ), |
| 224 | FoliosOp::Update => ( |
| 225 | with(json!({ |
| 226 | "title": { "type": "string", "description": "Its new title." }, |
| 227 | "icon": { "type": ["string", "null"], "description": "An emoji; null clears it." }, |
| 228 | "space": { "type": "string", "description": "Move it to the top of this space (slug or id), or `private` for your Private." }, |
| 229 | "parent_id": { "type": "string", "description": "Move it under this doc: its id." }, |
| 230 | "before_id": { "type": "string", "description": "Where it goes among its new siblings: before this one. Left out: last." }, |
| 231 | })), |
| 232 | one.to_vec(), |
| 233 | ), |
| 234 | FoliosOp::Edit => ( |
| 235 | with(json!({ |
| 236 | "markdown": { "type": "string", "description": "For a doc: the Markdown to add, or to replace the target with." }, |
| 237 | "target": { |
| 238 | "type": ["object", "string"], |
| 239 | "description": "For a doc: append or document (as a string or { \"kind\": … }), { \"kind\": \"section\", \"heading\": … }, or { \"kind\": \"blocks\", \"from_block\": …, \"to_block\": … }. Left out: append.", |
| 240 | }, |
| 241 | "ops": { "type": "array", "items": { "type": "object" }, "description": "For slides, designs and dashboards, once they ship: the kind's own ops." }, |
| 242 | "kind": { "type": "string", "enum": ["doc", "slides", "design", "dashboard"], "description": "The artifact's kind; left out, doc." }, |
| 243 | "note": { "type": "string", "description": "Why, in a line: shown with the version or suggestion." }, |
| 244 | "suggest_only": { "type": "boolean", "description": "File a suggestion even when you could edit." }, |
| 245 | "marks_current": { "type": "boolean", "description": "The edit brings it up to date with the code it cites." }, |
| 246 | })), |
| 247 | one.to_vec(), |
| 248 | ), |
| 249 | FoliosOp::RestoreVersion => ( |
| 250 | with(json!({ "version_id": { "type": "string", "description": "The version's id (ver_…), from its versions." } })), |
| 251 | [one.as_slice(), &["version_id"]].concat(), |
| 252 | ), |
| 253 | FoliosOp::SetAccess => ( |
| 254 | with(json!({ |
| 255 | "username": { "type": "string", "description": "A member to share it with." }, |
| 256 | "team": { "type": "string", "description": "A team of the workspace to share it with: its slug." }, |
| 257 | "agent": { "type": "string", "description": "An agent of the workspace to share it with: its id." }, |
| 258 | "principal": { "type": "string", "description": "Who, as its access list names them: user:…, agent:… or team:…" }, |
| 259 | "role": { "type": "string", "enum": ["view", "comment", "edit", "manage", "none"], "description": "What they may do: view, comment, edit, or manage (full access, sharing included); none takes their access away." }, |
| 260 | "notify": { "type": "string", "description": "A message for the person it is shared with." }, |
| 261 | "general_access": { "type": "string", "enum": ["none", "workspace", "link"], "description": "none (only people invited), workspace (everyone in the workspace) or link (anyone in the workspace with the link)." }, |
| 262 | "general_role": { "type": "string", "enum": ["view", "comment", "edit"], "description": "What general access gives; never full access." }, |
| 263 | "inherit": { "type": "boolean", "description": "Whether it follows its space or the doc it is under." }, |
| 264 | "agent_mode": { "type": ["string", "null"], "enum": ["suggest", "edit", null], "description": "How agents change it: suggest or edit; null follows its space." }, |
| 265 | })), |
| 266 | one.to_vec(), |
| 267 | ), |
| 268 | }; |
| 269 | json!({ "type": "object", "properties": properties, "required": required }) |
| 270 | } |
| 271 | } |
| 272 | |
| 273 | // --- Reading the input -------------------------------------------------------- |
| 274 | |
| 275 | fn text(input: &Value, key: &str) -> Option<String> { |
| 276 | input[key].as_str().map(str::trim).filter(|t| !t.is_empty()).map(str::to_owned) |
| 277 | } |
| 278 | |
| 279 | /// A whole number given as one, or as digits (a REST query). |
| 280 | fn number(input: &Value, key: &str) -> Option<u32> { |
| 281 | input[key] |
| 282 | .as_u64() |
| 283 | .or_else(|| text(input, key).and_then(|given| given.parse().ok())) |
| 284 | .map(|n| u32::try_from(n).unwrap_or(u32::MAX)) |
| 285 | } |
| 286 | |
| 287 | fn invalid(message: impl Into<String>) -> Outcome<Value> { |
| 288 | Outcome::fail(FailureCode::Invalid, message) |
| 289 | } |
| 290 | |
| 291 | /// The folio id an `artifact_id` names: an id, or an address or link that |
| 292 | /// ends in one. |
| 293 | pub(crate) fn artifact_id(given: &str) -> Option<String> { |
| 294 | let given = given.trim(); |
| 295 | let path = given.split(['?', '#']).next().unwrap_or_default(); |
| 296 | let last = path.trim_end_matches('/').rsplit('/').next().unwrap_or_default(); |
| 297 | folio_id_from(last).map(str::to_owned) |
| 298 | } |
| 299 | |
| 300 | fn kind_of(input: &Value) -> std::result::Result<Option<FolioKind>, String> { |
| 301 | match text(input, "kind") { |
| 302 | None => Ok(None), |
| 303 | Some(kind) => FolioKind::parse(&kind.to_lowercase()) |
| 304 | .map(Some) |
| 305 | .ok_or_else(|| format!("There is no kind of artifact called {kind}: doc, slides, design or dashboard.")), |
| 306 | } |
| 307 | } |
| 308 | |
| 309 | /// A doc edit's target, as the artifacts service reads it: a string is the |
| 310 | /// kind, an object passes as given. Left out, append. |
| 311 | fn edit_target(input: &Value) -> Value { |
| 312 | match &input["target"] { |
| 313 | Value::String(kind) => json!({ "kind": kind.trim().to_lowercase() }), |
| 314 | Value::Object(target) => Value::Object(target.clone()), |
| 315 | _ => json!({ "kind": "append" }), |
| 316 | } |
| 317 | } |
| 318 | |
| 319 | /// The `FolioAgentEdit` a call describes. |
| 320 | pub(crate) fn agent_edit(input: &Value) -> std::result::Result<Value, String> { |
| 321 | let kind = kind_of(input)?.unwrap_or(FolioKind::Doc); |
| 322 | let mut edit = Map::new(); |
| 323 | edit.insert("kind".to_owned(), json!(kind.as_str())); |
| 324 | if kind == FolioKind::Doc { |
| 325 | let Some(markdown) = input["markdown"].as_str() else { |
| 326 | return Err("Give the markdown to add, or to replace the target with.".to_owned()); |
| 327 | }; |
| 328 | edit.insert("markdown".to_owned(), json!(markdown)); |
| 329 | edit.insert("target".to_owned(), edit_target(input)); |
| 330 | } else { |
| 331 | edit.insert("ops".to_owned(), input["ops"].clone()); |
| 332 | } |
| 333 | for key in ["note", "suggest_only", "marks_current"] { |
| 334 | if let Some(value) = input.get(key).filter(|value| !value.is_null()) { |
| 335 | edit.insert(key.to_owned(), value.clone()); |
| 336 | } |
| 337 | } |
| 338 | let edit = Value::Object(edit); |
| 339 | agent_edit_error(&edit)?; |
| 340 | Ok(edit) |
| 341 | } |
| 342 | |
| 343 | /// What a call to the access route changes, in order: someone's role, |
| 344 | /// then general access, then following the space or parent, then how |
| 345 | /// agents change it. The principal is resolved by the caller. |
| 346 | pub(crate) fn access_changes(input: &Value, principal: Option<String>) -> std::result::Result<Vec<FolioAccessChange>, String> { |
| 347 | let mut changes = Vec::new(); |
| 348 | let role = text(input, "role").map(|role| role.to_lowercase()); |
| 349 | match (principal, role.as_deref()) { |
| 350 | (Some(principal), Some("none")) => changes.push(FolioAccessChange::Revoke { principal }), |
| 351 | (Some(principal), Some(role)) => { |
| 352 | let role = parse_role(role).ok_or_else(|| format!("{role} is not a role: view, comment, edit, manage or none."))?; |
| 353 | changes.push(FolioAccessChange::Grant { principal, role, notify: text(input, "notify") }); |
| 354 | } |
| 355 | (Some(_), None) => return Err("Give the role: view, comment, edit, manage, or none to take their access away.".to_owned()), |
| 356 | (None, Some(_)) => return Err("Give who to share it with: username, team, agent or principal.".to_owned()), |
| 357 | (None, None) => {} |
| 358 | } |
| 359 | if let Some(access) = text(input, "general_access") { |
| 360 | let access = match access.to_lowercase().as_str() { |
| 361 | "none" | "restricted" => GeneralAccess::None, |
| 362 | "workspace" => GeneralAccess::Workspace, |
| 363 | "link" => GeneralAccess::Link, |
| 364 | other => return Err(format!("{other} is not general access: none, workspace or link.")), |
| 365 | }; |
| 366 | let role = match (access, text(input, "general_role")) { |
| 367 | (GeneralAccess::None, _) => None, |
| 368 | (_, None) => Some(FolioRole::View), |
| 369 | (_, Some(role)) => Some(parse_role(&role.to_lowercase()).ok_or_else(|| format!("{role} is not a role: view, comment or edit."))?), |
| 370 | }; |
| 371 | changes.push(FolioAccessChange::General { access, role }); |
| 372 | } |
| 373 | if let Some(inherit) = input["inherit"].as_bool() { |
| 374 | changes.push(FolioAccessChange::Inherit { inherit }); |
| 375 | } |
| 376 | if let Some(mode) = input.get("agent_mode") { |
| 377 | let agent_mode = match mode.as_str().map(str::to_lowercase).as_deref() { |
| 378 | None if mode.is_null() => None, |
| 379 | Some("suggest") => Some(AgentMode::Suggest), |
| 380 | Some("edit") => Some(AgentMode::Edit), |
| 381 | _ => return Err("agent_mode is suggest, edit, or null to follow its space.".to_owned()), |
| 382 | }; |
| 383 | changes.push(FolioAccessChange::AgentMode { agent_mode }); |
| 384 | } |
| 385 | if changes.is_empty() { |
| 386 | return Err("Say what to change: who and a role, general_access, inherit or agent_mode.".to_owned()); |
| 387 | } |
| 388 | for change in &changes { |
| 389 | change.validate()?; |
| 390 | } |
| 391 | Ok(changes) |
| 392 | } |
| 393 | |
| 394 | fn parse_role(text: &str) -> Option<FolioRole> { |
| 395 | match text { |
| 396 | "view" | "read" => Some(FolioRole::View), |
| 397 | "comment" => Some(FolioRole::Comment), |
| 398 | "edit" | "write" => Some(FolioRole::Edit), |
| 399 | "manage" | "full" | "admin" => Some(FolioRole::Manage), |
| 400 | _ => None, |
| 401 | } |
| 402 | } |
| 403 | |
| 404 | // --- Answers, as the API shows them ------------------------------------------- |
| 405 | |
| 406 | /// A person, agent or team, from an artifacts service profile. |
| 407 | pub(crate) fn person_json(profile: &Value) -> Value { |
| 408 | if !profile.is_object() { |
| 409 | return Value::Null; |
| 410 | } |
| 411 | let kind = profile["kind"].as_str().unwrap_or("user"); |
| 412 | let name = profile["name"].clone(); |
| 413 | match kind { |
| 414 | "agent" => json!({ "type": "agent", "id": profile["id"], "handle": name, "display_name": profile["display_name"] }), |
| 415 | "team" => json!({ "type": "team", "slug": profile["id"], "display_name": profile["display_name"] }), |
| 416 | _ => json!({ "type": "user", "id": profile["id"], "username": name, "display_name": profile["display_name"] }), |
| 417 | } |
| 418 | } |
| 419 | |
| 420 | fn people_json(list: &Value) -> Value { |
| 421 | Value::Array(list.as_array().map(|list| list.iter().map(person_json).collect()).unwrap_or_default()) |
| 422 | } |
| 423 | |
| 424 | fn html_url(site: &str, path: &Value) -> Value { |
| 425 | match path.as_str() { |
| 426 | Some(path) => json!(format!("{}{}", site.trim_end_matches('/'), path)), |
| 427 | None => Value::Null, |
| 428 | } |
| 429 | } |
| 430 | |
| 431 | /// Enough to link to an artifact. |
| 432 | pub(crate) fn ref_json(folio: &Value, site: &str) -> Value { |
| 433 | json!({ |
| 434 | "id": folio["id"], |
| 435 | "kind": folio["kind"], |
| 436 | "title": folio["title"], |
| 437 | "icon": folio["icon"], |
| 438 | "slug": folio["slug"], |
| 439 | "html_url": html_url(site, &folio["path"]), |
| 440 | }) |
| 441 | } |
| 442 | |
| 443 | /// An artifact as the API shows one. |
| 444 | pub(crate) fn folio_json(folio: &Value, site: &str) -> Value { |
| 445 | let space = match &folio["space"] { |
| 446 | Value::Object(space) => json!({ "id": space.get("id"), "slug": space.get("slug"), "name": space.get("name"), "kind": space.get("kind") }), |
| 447 | _ => Value::Null, |
| 448 | }; |
| 449 | json!({ |
| 450 | "id": folio["id"], |
| 451 | "kind": folio["kind"], |
| 452 | "title": folio["title"], |
| 453 | "icon": folio["icon"], |
| 454 | "slug": folio["slug"], |
| 455 | "space": space, |
| 456 | "parent_id": folio["parent_id"], |
| 457 | "has_children": folio["has_children"], |
| 458 | "owner": person_json(&folio["owner"]), |
| 459 | "created_by": person_json(&folio["created_by"]), |
| 460 | "created_at": folio["created_at"], |
| 461 | "updated_at": folio["updated_at"], |
| 462 | "edited_by": person_json(&folio["edited_by"]), |
| 463 | "edited_at": folio["edited_at"], |
| 464 | "trashed_at": folio["trashed_at"], |
| 465 | "viewer_role": folio["viewer_role"], |
| 466 | "private": folio["private"], |
| 467 | "shared_count": folio["shared_count"], |
| 468 | "general_access": folio["general_access"], |
| 469 | "general_role": folio["general_role"], |
| 470 | "inherit": folio["inherit"], |
| 471 | "agent_mode": folio["agent_mode"], |
| 472 | "excerpt": folio["excerpt"], |
| 473 | "source": folio["source"], |
| 474 | "stale": folio["stale"], |
| 475 | "html_url": html_url(site, &folio["path"]), |
| 476 | }) |
| 477 | } |
| 478 | |
| 479 | fn content_json(read: &Value, site: &str) -> Value { |
| 480 | let mut artifact = ref_json(&read["folio"], site); |
| 481 | artifact["edited_at"] = read["folio"]["edited_at"].clone(); |
| 482 | let mut shown = json!({ |
| 483 | "artifact": artifact, |
| 484 | "space": read["space"], |
| 485 | "content": read["content"], |
| 486 | "can": read["can"], |
| 487 | }); |
| 488 | if let Some(blocks) = read.get("blocks").filter(|blocks| blocks.is_array()) { |
| 489 | shown["blocks"] = blocks.clone(); |
| 490 | } |
| 491 | shown |
| 492 | } |
| 493 | |
| 494 | fn suggestion_json(suggestion: &Value) -> Value { |
| 495 | json!({ |
| 496 | "id": suggestion["id"], |
| 497 | "status": suggestion["status"], |
| 498 | "target": suggestion["target"], |
| 499 | "before_markdown": suggestion["before_markdown"], |
| 500 | "after_markdown": suggestion["after_markdown"], |
| 501 | "note": suggestion["note"], |
| 502 | "author": person_json(&suggestion["author"]), |
| 503 | "created_at": suggestion["created_at"], |
| 504 | }) |
| 505 | } |
| 506 | |
| 507 | fn edit_json(result: &Value, site: &str) -> Value { |
| 508 | let mut shown = json!({ "mode": result["mode"], "artifact": ref_json(&result["folio"], site) }); |
| 509 | match result["mode"].as_str() { |
| 510 | Some("applied") => { |
| 511 | shown["version_id"] = result["version_id"].clone(); |
| 512 | shown["summary"] = result["summary"].clone(); |
| 513 | } |
| 514 | Some("suggested") => shown["suggestion"] = suggestion_json(&result["suggestion"]), |
| 515 | _ => {} |
| 516 | } |
| 517 | shown |
| 518 | } |
| 519 | |
| 520 | fn version_json(version: &Value) -> Value { |
| 521 | json!({ |
| 522 | "id": version["id"], |
| 523 | "artifact_id": version["folio_id"], |
| 524 | "created_at": version["created_at"], |
| 525 | "kind": version["kind"], |
| 526 | "note": version["note"], |
| 527 | "authors": people_json(&version["authors"]), |
| 528 | }) |
| 529 | } |
| 530 | |
| 531 | fn access_json(list: &Value) -> Value { |
| 532 | let rows: Vec<Value> = list["rows"] |
| 533 | .as_array() |
| 534 | .map(|rows| { |
| 535 | rows.iter() |
| 536 | .map(|row| { |
| 537 | let inherited = match row["source"]["kind"].as_str() { |
| 538 | Some("folio") => json!({ "artifact_id": row["source"]["id"], "title": row["source"]["title"] }), |
| 539 | _ => Value::Null, |
| 540 | }; |
| 541 | json!({ "principal": row["principal"], "member": person_json(&row["profile"]), "role": row["role"], "inherited_from": inherited }) |
| 542 | }) |
| 543 | .collect() |
| 544 | }) |
| 545 | .unwrap_or_default(); |
| 546 | let inherited_from = match &list["inherited_from"] { |
| 547 | Value::Object(from) => json!({ "type": from.get("kind").and_then(Value::as_str).map(|kind| if kind == "folio" { "artifact" } else { kind }), "id": from.get("id"), "name": from.get("name") }), |
| 548 | _ => Value::Null, |
| 549 | }; |
| 550 | json!({ |
| 551 | "artifact_id": list["folio_id"], |
| 552 | "owner": person_json(&list["owner"]), |
| 553 | "shared_with": rows, |
| 554 | "general_access": list["general_access"], |
| 555 | "general_role": list["general_role"], |
| 556 | "inherit": list["inherit"], |
| 557 | "inherited_from": inherited_from, |
| 558 | "agent_mode": list["agent_mode"], |
| 559 | "can_share": list["can_share"], |
| 560 | }) |
| 561 | } |
| 562 | |
| 563 | fn template_json(template: &Value) -> Value { |
| 564 | json!({ |
| 565 | "id": template["id"], |
| 566 | "kind": template["kind"], |
| 567 | "name": template["name"], |
| 568 | "description": template["description"], |
| 569 | "icon": template["icon"], |
| 570 | "builtin": template["builtin"], |
| 571 | "body": template["body"], |
| 572 | }) |
| 573 | } |
| 574 | |
| 575 | fn space_json(space: &Value) -> Value { |
| 576 | json!({ |
| 577 | "id": space["id"], |
| 578 | "slug": space["slug"], |
| 579 | "name": space["name"], |
| 580 | "description": space["description"], |
| 581 | "icon": space["icon"], |
| 582 | "kind": space["kind"], |
| 583 | "is_default": space["is_default"], |
| 584 | "joined": space["joined"], |
| 585 | "viewer_role": space["viewer_role"], |
| 586 | "artifact_count": space["page_count"], |
| 587 | }) |
| 588 | } |
| 589 | |
| 590 | fn hit_json(hit: &Value, site: &str) -> Value { |
| 591 | let mut shown = ref_json(hit, site); |
| 592 | for key in ["space_name", "snippet", "heading", "matched", "edited_at"] { |
| 593 | shown[key] = hit.get(key).cloned().unwrap_or(Value::Null); |
| 594 | } |
| 595 | shown |
| 596 | } |
| 597 | |
| 598 | fn mapped(outcome: Outcome<Value>, f: impl FnOnce(&Value) -> Value) -> Outcome<Value> { |
| 599 | match outcome { |
| 600 | Outcome::Ok(value) => Outcome::Ok(f(&value)), |
| 601 | Outcome::Fail(refused) => Outcome::Fail(refused), |
| 602 | } |
| 603 | } |
| 604 | |
| 605 | fn listed(outcome: Outcome<Value>, f: impl Fn(&Value) -> Value) -> Outcome<Value> { |
| 606 | mapped(outcome, |list| Value::Array(list.as_array().map(|list| list.iter().map(&f).collect()).unwrap_or_default())) |
| 607 | } |
| 608 | |
| 609 | // --- Running ------------------------------------------------------------------ |
| 610 | |
| 611 | async fn call<T: DeserializeOwned>(services: &Services, method: &str, args: &impl Serialize) -> Result<Outcome<T>> { |
| 612 | g1t_kit::call(&services.artifacts, method, args).await |
| 613 | } |
| 614 | |
| 615 | /// The person acting, or the refusal: nobody, or a workspace's own token. |
| 616 | pub(crate) fn person(viewer: &Viewer) -> std::result::Result<User, Outcome<Value>> { |
| 617 | match viewer { |
| 618 | None => Err(Outcome::fail(FailureCode::Unauthenticated, "This needs a g1t access token.")), |
| 619 | Some(user) if user.kind == PrincipalKind::Workspace => Err(Outcome::fail( |
| 620 | FailureCode::Forbidden, |
| 621 | "Artifacts belong to people: use a personal access token, or a g1t agent's.", |
| 622 | )), |
| 623 | Some(user) => Ok(user.clone()), |
| 624 | } |
| 625 | } |
| 626 | |
| 627 | /// A space's id from its slug or id, among the spaces in the person's |
| 628 | /// sidebar; an id passes as given. `Ok(None)` for `private`. |
| 629 | async fn space_id(services: &Services, workspace: &str, viewer: &User, given: &str) -> Result<std::result::Result<Option<String>, Outcome<Value>>> { |
| 630 | let given = given.trim(); |
| 631 | if given.eq_ignore_ascii_case("private") { |
| 632 | return Ok(Ok(None)); |
| 633 | } |
| 634 | if given.starts_with("spc_") { |
| 635 | return Ok(Ok(Some(given.to_owned()))); |
| 636 | } |
| 637 | let sidebar: Outcome<Value> = call(services, "folio_sidebar", &json!({ "workspace": workspace, "viewer": viewer })).await?; |
| 638 | let sidebar = match sidebar { |
| 639 | Outcome::Ok(sidebar) => sidebar, |
| 640 | Outcome::Fail(refused) => return Ok(Err(Outcome::Fail(refused))), |
| 641 | }; |
| 642 | let found = sidebar["spaces"] |
| 643 | .as_array() |
| 644 | .and_then(|spaces| spaces.iter().find(|space| space["slug"].as_str().is_some_and(|slug| slug.eq_ignore_ascii_case(given)))) |
| 645 | .and_then(|space| space["id"].as_str().map(str::to_owned)); |
| 646 | Ok(match found { |
| 647 | Some(id) => Ok(Some(id)), |
| 648 | None => Err(invalid(format!("There is no space called {given} in your sidebar: give its id, or join it first."))), |
| 649 | }) |
| 650 | } |
| 651 | |
| 652 | /// Who a share is for, as the artifacts service names them. |
| 653 | async fn principal(services: &Services, input: &Value) -> Result<std::result::Result<Option<String>, Outcome<Value>>> { |
| 654 | if let Some(principal) = text(input, "principal") { |
| 655 | return Ok(Ok(Some(principal))); |
| 656 | } |
| 657 | if let Some(team) = text(input, "team") { |
| 658 | let slug = team.trim_start_matches('@').rsplit('/').next().unwrap_or_default().to_lowercase(); |
| 659 | return Ok(Ok(Some(format!("team:{slug}")))); |
| 660 | } |
| 661 | if let Some(agent) = text(input, "agent") { |
| 662 | return Ok(Ok(Some(format!("agent:{agent}")))); |
| 663 | } |
| 664 | let Some(username) = text(input, "username").or_else(|| text(input, "user")) else { |
| 665 | return Ok(Ok(None)); |
| 666 | }; |
| 667 | let username = username.trim_start_matches('@').to_lowercase(); |
| 668 | let found: Viewer = g1t_kit::call(&services.identity, "user_by_username", &UsernameArgs { username: username.clone() }).await?; |
| 669 | Ok(match found { |
| 670 | Some(user) => Ok(Some(format!("user:{}", user.id))), |
| 671 | None => Err(invalid(format!("There is no account named {username}."))), |
| 672 | }) |
| 673 | } |
| 674 | |
| 675 | pub async fn run(op: FoliosOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> { |
| 676 | let site = services.addresses.site.clone(); |
| 677 | let viewer = match person(viewer) { |
| 678 | Ok(user) => user, |
| 679 | Err(refused) => return Ok(refused), |
| 680 | }; |
| 681 | let Some(workspace) = text(input, "workspace").map(|w| w.to_lowercase()) else { |
| 682 | return Ok(invalid("Give the workspace's slug.")); |
| 683 | }; |
| 684 | let kind = match kind_of(input) { |
| 685 | Ok(kind) => kind, |
| 686 | Err(message) => return Ok(invalid(message)), |
| 687 | }; |
| 688 | macro_rules! space { |
| 689 | ($given:expr) => { |
| 690 | match space_id(services, &workspace, &viewer, $given).await? { |
| 691 | Ok(id) => id, |
| 692 | Err(refused) => return Ok(refused), |
| 693 | } |
| 694 | }; |
| 695 | } |
| 696 | let folio_id = match op { |
| 697 | FoliosOp::List | FoliosOp::Search | FoliosOp::ListTemplates | FoliosOp::ListSpaces | FoliosOp::QueryDataset | FoliosOp::Create => String::new(), |
| 698 | _ => match text(input, "artifact_id").as_deref().and_then(artifact_id) { |
| 699 | Some(id) => id, |
| 700 | None => return Ok(invalid("Give the artifact_id: its id (fol_…) or its address.")), |
| 701 | }, |
| 702 | }; |
| 703 | let one = || FolioArgs { workspace: workspace.clone(), viewer: viewer.clone(), folio_id: folio_id.clone() }; |
| 704 | let shown = |folio: &Value| folio_json(folio, &site); |
| 705 | Ok(match op { |
| 706 | FoliosOp::List => { |
| 707 | if text(input, "state").as_deref() == Some("trashed") { |
| 708 | let found: Outcome<Value> = call(services, "folio_trash", &json!({ "workspace": workspace, "viewer": viewer })).await?; |
| 709 | return Ok(mapped(found, |list| json!({ "items": list.as_array().map(|list| list.iter().map(shown).collect::<Vec<_>>()).unwrap_or_default(), "next_cursor": null }))); |
| 710 | } |
| 711 | let tab = match text(input, "tab").map(|tab| tab.to_lowercase()).as_deref() { |
| 712 | None | Some("all") => FolioListTab::All, |
| 713 | Some("yours") | Some("mine") => FolioListTab::Yours, |
| 714 | Some("shared") => FolioListTab::Shared, |
| 715 | Some(other) => return Ok(invalid(format!("{other} is not a tab: all, yours or shared."))), |
| 716 | }; |
| 717 | let space_id = match text(input, "space") { |
| 718 | Some(given) => space!(&given), |
| 719 | None => None, |
| 720 | }; |
| 721 | let limit = number(input, "limit"); |
| 722 | let query = FolioListQuery { |
| 723 | tab, |
| 724 | kinds: kind.map(|kind| vec![kind]), |
| 725 | space_id, |
| 726 | owner: None, |
| 727 | project: text(input, "project"), |
| 728 | q: text(input, "q"), |
| 729 | cursor: text(input, "cursor"), |
| 730 | limit, |
| 731 | }; |
| 732 | if let Err(message) = query.validate() { |
| 733 | return Ok(invalid(message)); |
| 734 | } |
| 735 | let found: Outcome<Value> = call(services, "folio_list", &FolioListArgs { workspace, viewer, query }).await?; |
| 736 | mapped(found, |list| { |
| 737 | json!({ |
| 738 | "items": list["items"].as_array().map(|items| items.iter().map(shown).collect::<Vec<_>>()).unwrap_or_default(), |
| 739 | "next_cursor": list["next_cursor"], |
| 740 | }) |
| 741 | }) |
| 742 | } |
| 743 | FoliosOp::Search => { |
| 744 | let Some(q) = text(input, "q") else { return Ok(invalid("Give q: what to search for.")) }; |
| 745 | let space_id = match text(input, "space") { |
| 746 | Some(given) => space!(&given), |
| 747 | None => None, |
| 748 | }; |
| 749 | let limit = number(input, "limit").map(|l| l.clamp(1, 50)); |
| 750 | let query = FolioSearchQuery { q, kinds: kind.map(|kind| vec![kind]), space_id, project: text(input, "project"), owner: None, mode: Some("hybrid".to_owned()), limit }; |
| 751 | let found: Outcome<Value> = call(services, "search_folios", &SearchFoliosArgs { workspace, viewer, query }).await?; |
| 752 | listed(found, |hit| hit_json(hit, &site)) |
| 753 | } |
| 754 | FoliosOp::Get => mapped(call(services, "folio", &one()).await?, shown), |
| 755 | FoliosOp::GetContent => mapped(call(services, "folio_content", &one()).await?, |read| content_json(read, &site)), |
| 756 | FoliosOp::ListVersions => listed(call(services, "folio_versions", &one()).await?, version_json), |
| 757 | FoliosOp::GetAccess => mapped(call(services, "folio_access", &one()).await?, access_json), |
| 758 | FoliosOp::ListTemplates => { |
| 759 | let found: Outcome<Value> = call(services, "folio_templates", &FolioTemplatesArgs { workspace, viewer, kind }).await?; |
| 760 | listed(found, template_json) |
| 761 | } |
| 762 | FoliosOp::ListSpaces => { |
| 763 | let found: Outcome<Value> = call(services, "folio_sidebar", &json!({ "workspace": workspace, "viewer": viewer })).await?; |
| 764 | mapped(found, |sidebar| Value::Array(sidebar["spaces"].as_array().map(|spaces| spaces.iter().map(space_json).collect()).unwrap_or_default())) |
| 765 | } |
| 766 | FoliosOp::QueryDataset => { |
| 767 | let query: DatasetQuery = match serde_json::from_value(input["query"].clone()) { |
| 768 | Ok(query) => query, |
| 769 | Err(error) => return Ok(invalid(format!("That query does not read: {error}."))), |
| 770 | }; |
| 771 | if let Err(message) = query.validate() { |
| 772 | return Ok(invalid(message)); |
| 773 | } |
| 774 | call(services, "query_dataset", &QueryDatasetArgs { workspace, viewer, query }).await? |
| 775 | } |
| 776 | FoliosOp::Create => { |
| 777 | let kind = kind.unwrap_or(FolioKind::Doc); |
| 778 | let space_id = match text(input, "space") { |
| 779 | Some(given) => space!(&given), |
| 780 | None => None, |
| 781 | }; |
| 782 | let content = input["markdown"].as_str().map(|markdown| FolioContentInput { markdown: Some(markdown.to_owned()), spec: None }); |
| 783 | let new = NewFolio { |
| 784 | kind, |
| 785 | title: text(input, "title"), |
| 786 | icon: text(input, "icon"), |
| 787 | space_id, |
| 788 | parent_id: text(input, "parent_id"), |
| 789 | template_id: text(input, "template_id"), |
| 790 | content, |
| 791 | source: None, |
| 792 | share_with: None, |
| 793 | }; |
| 794 | if let Err(message) = new.validate() { |
| 795 | return Ok(invalid(message)); |
| 796 | } |
| 797 | mapped(call(services, "create_folio", &CreateFolioArgs { workspace, viewer, input: new }).await?, shown) |
| 798 | } |
| 799 | FoliosOp::Update => { |
| 800 | let mut change = FolioChange::default(); |
| 801 | if let Some(title) = input["title"].as_str() { |
| 802 | if title.chars().count() > MAX_TITLE { |
| 803 | return Ok(invalid(format!("A title is at most {MAX_TITLE} characters."))); |
| 804 | } |
| 805 | change.title = Some(title.to_owned()); |
| 806 | } |
| 807 | if let Some(icon) = input.get("icon") { |
| 808 | change.icon = Some(icon.as_str().map(str::to_owned)); |
| 809 | } |
| 810 | let moving = input.get("space").is_some_and(|v| !v.is_null()) || input.get("parent_id").is_some_and(|v| !v.is_null()); |
| 811 | if change == FolioChange::default() && !moving { |
| 812 | return Ok(invalid("Say what to change: title, icon, space or parent_id.")); |
| 813 | } |
| 814 | let mut answer: Option<Outcome<Value>> = None; |
| 815 | if change != FolioChange::default() { |
| 816 | let changed: Outcome<Value> = call(services, "update_folio", &UpdateFolioArgs { workspace: workspace.clone(), viewer: viewer.clone(), folio_id: folio_id.clone(), change }).await?; |
| 817 | if let Outcome::Fail(_) = changed { |
| 818 | return Ok(changed); |
| 819 | } |
| 820 | answer = Some(changed); |
| 821 | } |
| 822 | if moving { |
| 823 | let parent_id = text(input, "parent_id"); |
| 824 | let space_id = match (&parent_id, text(input, "space")) { |
| 825 | (None, Some(given)) => space!(&given), |
| 826 | _ => None, |
| 827 | }; |
| 828 | let to = FolioMove { space_id, parent_id, before_id: text(input, "before_id") }; |
| 829 | answer = Some(call(services, "move_folio", &MoveFolioArgs { workspace, viewer, folio_id, to }).await?); |
| 830 | } |
| 831 | mapped(answer.unwrap_or_else(|| invalid("Nothing to change.")), shown) |
| 832 | } |
| 833 | FoliosOp::Edit => { |
| 834 | let edit = match agent_edit(input) { |
| 835 | Ok(edit) => edit, |
| 836 | Err(message) => return Ok(invalid(message)), |
| 837 | }; |
| 838 | mapped(call(services, "edit_folio", &EditFolioArgs { workspace, viewer, folio_id, edit }).await?, |result| edit_json(result, &site)) |
| 839 | } |
| 840 | FoliosOp::Trash => mapped(call(services, "trash_folio", &one()).await?, shown), |
| 841 | FoliosOp::Restore => mapped(call(services, "restore_folio", &one()).await?, shown), |
| 842 | FoliosOp::Purge => mapped(call(services, "delete_folio", &one()).await?, |_| json!({ "deleted": true })), |
| 843 | FoliosOp::RestoreVersion => { |
| 844 | let Some(version_id) = text(input, "version_id") else { return Ok(invalid("Give the version_id.")) }; |
| 845 | let args = FolioVersionArgs { workspace, viewer, folio_id, version_id }; |
| 846 | mapped(call(services, "restore_folio_version", &args).await?, version_json) |
| 847 | } |
| 848 | FoliosOp::SetAccess => { |
| 849 | let principal = match principal(services, input).await? { |
| 850 | Ok(principal) => principal, |
| 851 | Err(refused) => return Ok(refused), |
| 852 | }; |
| 853 | let changes = match access_changes(input, principal) { |
| 854 | Ok(changes) => changes, |
| 855 | Err(message) => return Ok(invalid(message)), |
| 856 | }; |
| 857 | let mut last: Outcome<Value> = invalid("Nothing to change."); |
| 858 | for change in changes { |
| 859 | let method = change.method(); |
| 860 | let args = FolioAccessArgs { workspace: workspace.clone(), viewer: viewer.clone(), folio_id: folio_id.clone(), change }; |
| 861 | last = call(services, method, &args).await?; |
| 862 | if let Outcome::Fail(_) = last { |
| 863 | return Ok(last); |
| 864 | } |
| 865 | } |
| 866 | mapped(last, access_json) |
| 867 | } |
| 868 | }) |
| 869 | } |
| 870 | |
| 871 | #[cfg(test)] |
| 872 | mod tests { |
| 873 | use super::*; |
| 874 | |
| 875 | fn folio() -> Value { |
| 876 | json!({ |
| 877 | "id": "fol_0123456789abcdefghjkmnpqrs", |
| 878 | "kind": "doc", |
| 879 | "title": "Q4 roadmap", |
| 880 | "icon": null, |
| 881 | "slug": "q4-roadmap-fol_0123456789abcdefghjkmnpqrs", |
| 882 | "path": "/acme/-/artifacts/q4-roadmap-fol_0123456789abcdefghjkmnpqrs", |
| 883 | "workspace_id": "ws_1", |
| 884 | "space": { "id": "spc_1", "slug": "general", "name": "General", "kind": "workspace" }, |
| 885 | "parent_id": null, |
| 886 | "position": 1024, |
| 887 | "owner": { "kind": "user", "id": "usr_1", "name": "ana", "display_name": "Ana", "avatar": null, "role": null }, |
| 888 | "created_by": { "kind": "agent", "id": "agt_1", "name": "g1t", "display_name": "g1t", "avatar": null, "role": null }, |
| 889 | "created_at": "2026-10-01T00:00:00.000Z", |
| 890 | "updated_at": "2026-10-02T00:00:00.000Z", |
| 891 | "edited_by": null, |
| 892 | "edited_at": "2026-10-02T00:00:00.000Z", |
| 893 | "trashed_at": null, |
| 894 | "viewer_role": "manage", |
| 895 | "favorite": false, |
| 896 | "private": false, |
| 897 | "shared_count": 2, |
| 898 | "general_access": "workspace", |
| 899 | "general_role": "view", |
| 900 | "inherit": true, |
| 901 | "inherited_from": null, |
| 902 | "agent_mode": "suggest", |
| 903 | "excerpt": "What we ship.", |
| 904 | "preview": null, |
| 905 | "source": null, |
| 906 | "stale": false, |
| 907 | "has_children": false, |
| 908 | }) |
| 909 | } |
| 910 | |
| 911 | #[test] |
| 912 | fn an_artifact_is_snake_case_with_its_page() { |
| 913 | let shown = folio_json(&folio(), "https://g1t.sh/"); |
| 914 | assert_eq!(shown["html_url"], "https://g1t.sh/acme/-/artifacts/q4-roadmap-fol_0123456789abcdefghjkmnpqrs"); |
| 915 | assert_eq!(shown["owner"], json!({ "type": "user", "id": "usr_1", "username": "ana", "display_name": "Ana" })); |
| 916 | assert_eq!(shown["created_by"]["type"], "agent"); |
| 917 | assert_eq!(shown["edited_by"], Value::Null); |
| 918 | assert_eq!(shown["space"]["slug"], "general"); |
| 919 | assert!(shown.get("preview").is_none() && shown.get("workspace_id").is_none()); |
| 920 | assert!(g1t_kit::wire::camel_case_keys(&shown).is_empty()); |
| 921 | } |
| 922 | |
| 923 | #[test] |
| 924 | fn an_artifact_is_named_by_its_id_or_any_address_ending_in_it() { |
| 925 | let id = "fol_0123456789abcdefghjkmnpqrs"; |
| 926 | assert_eq!(artifact_id(id).as_deref(), Some(id)); |
| 927 | assert_eq!(artifact_id("q4-roadmap-fol_0123456789abcdefghjkmnpqrs").as_deref(), Some(id)); |
| 928 | assert_eq!(artifact_id("https://g1t.sh/acme/-/artifacts/q4-roadmap-fol_0123456789abcdefghjkmnpqrs?x=1#h").as_deref(), Some(id)); |
| 929 | assert_eq!(artifact_id("/acme/-/artifacts/q4-roadmap-fol_0123456789abcdefghjkmnpqrs/").as_deref(), Some(id)); |
| 930 | assert_eq!(artifact_id("q4-roadmap"), None); |
| 931 | assert_eq!(artifact_id("../fol_x"), None); |
| 932 | } |
| 933 | |
| 934 | #[test] |
| 935 | fn an_edit_is_a_doc_edit_unless_it_says_otherwise() { |
| 936 | let edit = agent_edit(&json!({ "markdown": "## Next\n\nMore." })).unwrap(); |
| 937 | assert_eq!(edit, json!({ "kind": "doc", "markdown": "## Next\n\nMore.", "target": { "kind": "append" } })); |
| 938 | let edit = agent_edit(&json!({ "markdown": "x", "target": "Document", "note": "Rewrite", "suggest_only": true })).unwrap(); |
| 939 | assert_eq!(edit["target"], json!({ "kind": "document" })); |
| 940 | assert_eq!(edit["note"], "Rewrite"); |
| 941 | assert_eq!(edit["suggest_only"], true); |
| 942 | let edit = agent_edit(&json!({ "markdown": "x", "target": { "kind": "section", "heading": "Risks" } })).unwrap(); |
| 943 | assert_eq!(edit["target"]["heading"], "Risks"); |
| 944 | assert_eq!(agent_edit(&json!({ "markdown": "x", "target": { "kind": "section" } })), Err("A section target names its heading.".to_owned())); |
| 945 | assert!(agent_edit(&json!({})).unwrap_err().contains("markdown")); |
| 946 | // Other kinds carry ops, which the artifacts service checks (and refuses |
| 947 | // until each kind ships). |
| 948 | let edit = agent_edit(&json!({ "kind": "slides", "ops": [{ "op": "add_slide" }] })).unwrap(); |
| 949 | assert_eq!(edit["kind"], "slides"); |
| 950 | assert_eq!(agent_edit(&json!({ "kind": "slides" })), Err("A deck edit has a list of ops.".to_owned())); |
| 951 | assert!(agent_edit(&json!({ "kind": "poster", "markdown": "x" })).unwrap_err().contains("poster")); |
| 952 | } |
| 953 | |
| 954 | #[test] |
| 955 | fn sharing_reads_one_change_of_each_kind_in_order() { |
| 956 | let changes = access_changes( |
| 957 | &json!({ "role": "edit", "notify": "Have a look", "general_access": "workspace", "inherit": false, "agent_mode": null }), |
| 958 | Some("user:usr_2".to_owned()), |
| 959 | ) |
| 960 | .unwrap(); |
| 961 | assert_eq!( |
| 962 | changes, |
| 963 | vec![ |
| 964 | FolioAccessChange::Grant { principal: "user:usr_2".into(), role: FolioRole::Edit, notify: Some("Have a look".into()) }, |
| 965 | FolioAccessChange::General { access: GeneralAccess::Workspace, role: Some(FolioRole::View) }, |
| 966 | FolioAccessChange::Inherit { inherit: false }, |
| 967 | FolioAccessChange::AgentMode { agent_mode: None }, |
| 968 | ] |
| 969 | ); |
| 970 | assert_eq!(changes[0].method(), "set_folio_grant"); |
| 971 | assert_eq!(changes[1].method(), "set_folio_general_access"); |
| 972 | assert_eq!( |
| 973 | access_changes(&json!({ "role": "none" }), Some("team:design".into())).unwrap(), |
| 974 | vec![FolioAccessChange::Revoke { principal: "team:design".into() }] |
| 975 | ); |
| 976 | assert_eq!( |
| 977 | access_changes(&json!({ "general_access": "none", "general_role": "edit" }), None).unwrap(), |
| 978 | vec![FolioAccessChange::General { access: GeneralAccess::None, role: None }] |
| 979 | ); |
| 980 | // General access never gives full access; that is shared by name. |
| 981 | assert_eq!( |
| 982 | access_changes(&json!({ "general_access": "link", "general_role": "manage" }), None), |
| 983 | Err("General access gives view, comment or edit, never full access.".to_owned()) |
| 984 | ); |
| 985 | assert!(access_changes(&json!({ "role": "edit" }), None).unwrap_err().contains("who")); |
| 986 | assert!(access_changes(&json!({}), Some("user:usr_2".into())).unwrap_err().contains("role")); |
| 987 | assert!(access_changes(&json!({}), None).unwrap_err().starts_with("Say what to change")); |
| 988 | assert!(access_changes(&json!({ "role": "owner" }), Some("user:usr_2".into())).unwrap_err().contains("owner")); |
| 989 | assert!(access_changes(&json!({ "role": "view" }), Some("someone".into())).unwrap_err().contains("user:, agent: or team:")); |
| 990 | } |
| 991 | |
| 992 | #[test] |
| 993 | fn a_workspace_token_is_refused_and_nobody_is_asked_to_sign_in() { |
| 994 | let workspace = User { id: "ws_1".into(), username: "acme".into(), kind: PrincipalKind::Workspace, ..User::default() }; |
| 995 | let Err(Outcome::Fail(refused)) = person(&Some(workspace)) else { panic!("a workspace token") }; |
| 996 | assert_eq!(refused.code, FailureCode::Forbidden); |
| 997 | let Err(Outcome::Fail(refused)) = person(&None) else { panic!("nobody") }; |
| 998 | assert_eq!(refused.code, FailureCode::Unauthenticated); |
| 999 | assert!(person(&Some(User { id: "usr_1".into(), username: "ana".into(), ..User::default() })).is_ok()); |
| 1000 | } |
| 1001 | |
| 1002 | #[test] |
| 1003 | fn answers_are_snake_case_and_name_people_the_api_way() { |
| 1004 | let site = "https://g1t.sh"; |
| 1005 | let access = access_json(&json!({ |
| 1006 | "folio_id": "fol_1", |
| 1007 | "owner": { "kind": "user", "id": "usr_1", "name": "ana", "display_name": "Ana" }, |
| 1008 | "rows": [ |
| 1009 | { "principal": "team:design", "profile": { "kind": "team", "id": "design", "name": "design", "display_name": "@acme/design" }, "role": "edit", "source": { "kind": "folio", "id": "fol_0", "title": "Plans", "path": "/acme/-/artifacts/plans-fol_0" } }, |
| 1010 | { "principal": "user:usr_2", "profile": { "kind": "user", "id": "usr_2", "name": "bo", "display_name": "Bo" }, "role": "view", "source": { "kind": "grant" } }, |
| 1011 | ], |
| 1012 | "general_access": "none", |
| 1013 | "general_role": null, |
| 1014 | "inherit": true, |
| 1015 | "inherited_from": { "kind": "folio", "id": "fol_0", "name": "Plans" }, |
| 1016 | "agent_mode": null, |
| 1017 | "can_share": true, |
| 1018 | "public_link": "off", |
| 1019 | })); |
| 1020 | assert_eq!(access["shared_with"][0]["member"], json!({ "type": "team", "slug": "design", "display_name": "@acme/design" })); |
| 1021 | assert_eq!(access["shared_with"][0]["inherited_from"]["artifact_id"], "fol_0"); |
| 1022 | assert_eq!(access["shared_with"][1]["inherited_from"], Value::Null); |
| 1023 | assert_eq!(access["inherited_from"]["type"], "artifact"); |
| 1024 | assert!(access.get("public_link").is_none()); |
| 1025 | let content = content_json( |
| 1026 | &json!({ "folio": { "id": "fol_1", "kind": "doc", "title": "T", "icon": null, "slug": "t-fol_1", "path": "/acme/-/artifacts/t-fol_1", "edited_at": "2026-10-02T00:00:00.000Z" }, "space": null, "content": "# T", "blocks": [{ "id": "b1", "type": "heading", "level": 1, "markdown": "# T" }], "can": { "read": true, "suggest": true, "edit": true }, "audience_can_read": true }), |
| 1027 | site, |
| 1028 | ); |
| 1029 | assert_eq!(content["artifact"]["html_url"], "https://g1t.sh/acme/-/artifacts/t-fol_1"); |
| 1030 | assert!(content.get("audience_can_read").is_none()); |
| 1031 | let edit = edit_json(&json!({ "mode": "applied", "version_id": "ver_1", "folio": { "id": "fol_1", "path": "/acme/-/artifacts/t-fol_1" }, "summary": "Added a section." }), site); |
| 1032 | assert_eq!(edit["version_id"], "ver_1"); |
| 1033 | for shown in [access, content, edit, version_json(&json!({ "id": "ver_1", "folio_id": "fol_1", "authors": [{ "kind": "user", "id": "usr_1", "name": "ana", "display_name": "Ana" }] }))] { |
| 1034 | assert!(g1t_kit::wire::camel_case_keys(&shown).is_empty(), "{shown}"); |
| 1035 | } |
| 1036 | } |
| 1037 | |
| 1038 | #[test] |
| 1039 | fn each_operation_is_described_with_a_schema_and_a_scope() { |
| 1040 | use g1t_contracts::scopes::{Level, Scope, scope_for}; |
| 1041 | for op in FoliosOp::ALL { |
| 1042 | assert!(crate::operations::Op::ALL.contains(&crate::operations::Op::Folios(op)), "{}", op.name()); |
| 1043 | assert!(!op.title().is_empty() && op.description().len() > 80, "{}", op.name()); |
| 1044 | assert!(op.input()["required"].as_array().unwrap().contains(&json!("workspace")), "{}", op.name()); |
| 1045 | let scope = scope_for(op.name()).unwrap(); |
| 1046 | assert_eq!(scope.resource(), g1t_contracts::scopes::Resource::Artifacts, "{}", op.name()); |
| 1047 | assert_eq!(op.writes(), scope.level() != Level::Read, "{}", op.name()); |
| 1048 | } |
| 1049 | // Sharing and deleting for good are the admin scope's alone. |
| 1050 | let admin: Vec<FoliosOp> = FoliosOp::ALL.into_iter().filter(|op| scope_for(op.name()) == Some(Scope::ArtifactsAdmin)).collect(); |
| 1051 | assert_eq!(admin, vec![FoliosOp::SetAccess, FoliosOp::Purge]); |
| 1052 | } |
| 1053 | } |