Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 13 additions & 9 deletions polis-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,8 @@ endpoints deliberately when retaining output.
| 8 | Planned capability unavailable |

`conversation list` uses the current API's bounded limit (default 100, maximum 9999).
There is no cursor: reaching the limit sets `truncated:true`, conservatively. No
There is no offset or cursor: when the site returned `--limit` rows,
`pagination.truncated` is `true`, conservatively; raise `--limit` to read more. No
invented pagination or exhaustive-list claim is made. Raw GETs may cause work, so
`api` transport failures conservatively use mutation outcome handling. Quota/usage
always reports `unavailable`; this client does not infer billing authority from roles.
Expand Down Expand Up @@ -252,7 +253,7 @@ The first real pol.is sign-in is made by an operator, never by the tests.
```sh
polis auth login
polis conversation list --mine --recent --limit 25
polis conversation list --offset 25 --active true --draft false --search 'Topic'
polis conversation list --limit 500 --active true --draft false --search 'Topic'
```

The registered production callback is `http://127.0.0.1:53147/oauth/callback`.
Expand All @@ -269,13 +270,16 @@ with issuer/client/audience binding and silent renewal. No ID token is stored.
Logout erases local credentials but does not end the issuer browser session. Without OAuth configuration, existing token/env login remains available; with a configured issuer, login starts a fresh browser flow. A failed forced login
leaves a previously saved credential unchanged.

Conversation listing now requires the separate owner-list server patch. It returns
only the authenticated owner's rows, sorted by creation descending with zid as the
tie breaker, and explicit `pagination.has_more` / `pagination.next_offset`. `--mine`
and `--recent` name the defaults. Active/draft filters run before pagination; search
is a literal case-insensitive topic substring, including `%` and `_`. Offset pages
are live views: concurrent creates/deletes can shift subsequent pages. Legacy
servers return an actionable capability error, never an apparently complete list.
Conversation listing reads the current `GET /api/v3/conversations`, which answers
with an array of the conversations the account owns or shares a site with, newest
created first, at most `--limit` rows; it has no offset, cursor, owner filter or
search parameter. The CLI keeps only rows whose server-set `is_owner` is `true`, so
`pagination.returned` can be below `pagination.server_rows`; `pagination.truncated`
is `true` when the site returned `--limit` rows. `--mine` and `--recent` name the
defaults. `--active` and `--draft` are applied by the site; `--search` is a literal,
case-insensitive topic substring (including `%` and `_`) applied to the rows read.
An answer that is not an array of rows with a boolean `is_owner` is refused as
`invalid_response`, never shown as a list.

Moderation, report view/export and stored Delphi reads retain the previous pass's
semantics. Plan view verifies the account and reports billing/quota unavailable.
Expand Down
7 changes: 6 additions & 1 deletion polis-cli/crates/polis/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ fn leaf(path: &str) -> Command {
"report create" => c.arg(arg("conversation","Public conversation ID").required(true)).after_help("EXAMPLE\n polis report create --conversation CONV --json\n polis report list --conversation CONV --json\n\nRequires moderator access. Creates report configuration with the existing default mod_level=-2; starts no math, export or Delphi generation. The legacy POST has no created ID or idempotency key. data.report_id stays null; data.reports is an uncorrelated readback list, never proof of which report was created. A failed readback preserves the accepted receipt. Never repeat creation to recover a lost ID; list first. An uncertain write exits 7."),
"account view" => c.after_help("EXAMPLE\n polis account view --json\n\nRequires authentication. Returns this account's server profile, which may include personal data. It is not a plan, credit balance, or proof of permission for another resource."),
"conversation create" => c.arg(arg("topic","Conversation topic (1–1000 UTF-16 units)").required(true)).arg(arg("description","Optional description (up to 50000 UTF-16 units)")).arg(flag("draft","Create a draft")),
"conversation list" => c.arg(flag("mine","Owned conversations only (the default)")).arg(flag("recent","Newest created first (the default; zid breaks ties)")).arg(arg("limit","Page size").default_value("100").value_parser(clap::value_parser!(u16).range(1..=500))).arg(arg("offset","Rows to skip; use pagination.next_offset").default_value("0").value_parser(clap::value_parser!(u32).range(0..=99999999))).arg(arg("active","Filter active or closed").value_parser(["true","false"])).arg(arg("draft","Filter draft or published").value_parser(["true","false"])).arg(arg("search","Case-insensitive topic substring (literal)")),
"conversation list" => c.arg(flag("mine","Owned conversations only (the default)")).arg(flag("recent","Newest created first (the default)")).arg(arg("limit","Rows read from the site before the owner filter (1–9999)").default_value("100").value_parser(clap::value_parser!(u16).range(1..=9999))).arg(arg("active","Filter active or closed").value_parser(["true","false"])).arg(arg("draft","Filter draft or published").value_parser(["true","false"])).arg(arg("search","Case-insensitive topic substring (literal), applied to the rows read")).after_help("EXAMPLES\n polis conversation list --json\n polis conversation list --limit 500 --active true --json\n polis conversation list --search 'budget' --json\n\nReads GET /api/v3/conversations. The current API returns an array of the conversations this account owns or shares a site with, newest created first, at most --limit rows; it has no offset, cursor, owner filter or search parameter. Rows whose server-set is_owner is false are dropped here, so pagination.returned can be below pagination.server_rows. pagination.truncated is true when the site returned --limit rows: raise --limit to read more. --active and --draft are applied by the site; --search is a literal, case-insensitive topic match applied to the rows read. Requires sign-in. One bounded read, 8 MiB and --timeout; no automatic retry."),
"conversation view" | "conversation close" | "conversation reopen" => c.arg(pos("id",true)),
"participant export"|"participant xid export" => c.arg(arg("conversation","Public conversation ID").required(true)).arg(arg("output","New output file; never overwrite").required(true)).arg(arg("format","CSV preserves bytes; JSON retains every CSV cell as a string").value_parser(["csv","json"]).default_value("csv")).after_help("Uses the moderator-enforced XID export. Only participants with XIDs are included, not every participant. Server caps this endpoint at 1,000,000 rows; CLI bounds bytes at 8 MiB. Downloaded cells are untrusted text."),
"plan view" => c.arg(flag("web","Open this site account page; no checkout or plan change")).arg(flag("no-browser","Return the account URL without opening it").requires("web")),
Expand Down Expand Up @@ -348,6 +348,11 @@ report exports, stored Delphi results, and raw API.\nThe full product surface is
.about(about)
.subcommand_required(true)
.arg_required_else_help(true);
if group == "conversation" {
// `polis conversations list` is what people type first; it parses as the
// canonical group, so command paths, schema and help are unchanged.
cmd = cmd.visible_alias("conversations");
}
let mut nested: std::collections::BTreeMap<&str, Vec<&str>> =
std::collections::BTreeMap::new();
for op in leaves.split_whitespace() {
Expand Down
76 changes: 58 additions & 18 deletions polis-cli/crates/polis/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,56 @@ fn client(config: &profiles::Config, site: &str, timeout: u64, required: bool) -
}
Client::with_ca(url, token, timeout, config.auth(site)?.ca_file.as_deref())
}
/// `GET /api/v3/conversations` answers with an array: every conversation the account
/// owns or shares a site with, newest created first, at most `limit` rows, each row
/// carrying the server-set `is_owner`. The owner-only view is selected here from that
/// flag; the API has no owner filter, offset, cursor or search parameter.
fn owned_list(data: Value, limit: u16, search: Option<&str>) -> Result<Value> {
let rows = data
.as_array()
.ok_or_else(|| unrecognized_list("Expected a conversation array."))?;
let needle = search.map(str::to_lowercase);
let mut conversations = Vec::new();
for row in rows {
let is_owner = row
.get("is_owner")
.and_then(Value::as_bool)
.ok_or_else(|| {
unrecognized_list("A conversation row lacks the boolean is_owner flag.")
})?;
if !is_owner {
continue;
}
if let Some(needle) = &needle {
let topic = row.get("topic").and_then(Value::as_str).unwrap_or("");
if !topic.to_lowercase().contains(needle.as_str()) {
continue;
}
}
conversations.push(row.clone());
}
let returned = conversations.len();
Ok(json!({
"scope": "owner",
"conversations": conversations,
"pagination": {
"limit": limit,
"server_rows": rows.len(),
"returned": returned,
"truncated": rows.len() >= usize::from(limit),
},
}))
}
fn unrecognized_list(message: &str) -> Error {
let mut error = Error::new(
"invalid_response",
message,
"Check site compatibility; no conversation list was read.",
1,
);
error.outcome = "rejected";
error
}
fn execute(root: &ArgMatches, config: &mut profiles::Config, site: &str) -> Result<Value> {
let timeout = *root.get_one::<u64>("timeout").unwrap();
let mut m = root;
Expand Down Expand Up @@ -260,17 +310,9 @@ fn execute(root: &ArgMatches, config: &mut profiles::Config, site: &str) -> Resu
}
"conversation list" => {
let limit = *m.get_one::<u16>("limit").unwrap();
let offset = *m.get_one::<u32>("offset").unwrap();
let mut query = vec![
("mine".into(), "true".into()),
("limit".into(), limit.to_string()),
("offset".into(), offset.to_string()),
];
for (arg, key) in [
("active", "is_active"),
("draft", "is_draft"),
("search", "search"),
] {
// Only parameters the current API reads: limit, is_active, is_draft.
let mut query = vec![("limit".into(), limit.to_string())];
for (arg, key) in [("active", "is_active"), ("draft", "is_draft")] {
if let Some(value) = m.get_one::<String>(arg) {
query.push((key.into(), value.clone()));
}
Expand All @@ -282,13 +324,11 @@ fn execute(root: &ArgMatches, config: &mut profiles::Config, site: &str) -> Resu
None,
false,
)?;
if data["scope"] != "owner"
|| !data["conversations"].is_array()
|| !data["pagination"].is_object()
{
return Err(Error::new("capability_unavailable", "The server does not support owner-only paginated listing.", "Deploy the owner-list server patch; a legacy array cannot prove complete owner pagination.", 8));
}
Ok(data)
owned_list(
data,
limit,
m.get_one::<String>("search").map(String::as_str),
)
}
"conversation view" => {
let id = str_arg(m, "id");
Expand Down
108 changes: 88 additions & 20 deletions polis-cli/crates/polis/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,51 +127,119 @@ fn empty_list_is_success() {
let (o, r) = fixture(
200,
"application/json",
r#"{"scope":"owner","conversations":[],"pagination":{"limit":2,"offset":0,"returned":0,"has_more":false,"next_offset":null}}"#,
"[]",
&["conversation", "list", "--json", "--limit", "2"],
);
assert!(o.status.success());
assert!(r.starts_with("GET /api/v3/conversations?mine=true&limit=2&offset=0 "));
assert_eq!(parsed(&o)["data"]["pagination"]["has_more"], false);
// Only parameters the current API reads are sent; the bearer token goes in the header.
assert!(r.starts_with("GET /api/v3/conversations?limit=2 "), "{r}");
assert!(r
.to_ascii_lowercase()
.contains("authorization: bearer public-fixture-token"));
let data = &parsed(&o)["data"];
assert_eq!(data["scope"], "owner");
assert_eq!(data["conversations"], json!([]));
assert_eq!(
data["pagination"],
json!({"limit":2,"server_rows":0,"returned":0,"truncated":false})
);
}
#[test]
fn legacy_list_cannot_claim_owner_pagination() {
let (o, _) = fixture(
fn list_keeps_owned_rows_and_reports_truncation() {
// The API's array mixes owned rows with the site's shared rows (is_owner false).
let (o, r) = fixture(
200,
"application/json",
r#"[{"conversation_id":"generated"}]"#,
&["conversation", "list", "--json", "--limit", "1"],
r#"[{"conversation_id":"abc","topic":"Budget 2027","is_owner":true,"is_mod":true},{"conversation_id":"def","topic":"Shared site row","is_owner":false,"is_mod":true}]"#,
&[
"conversation",
"list",
"--mine",
"--recent",
"--limit",
"2",
"--json",
],
);
assert!(o.status.success());
assert!(r.starts_with("GET /api/v3/conversations?limit=2 "), "{r}");
let data = &parsed(&o)["data"];
assert_eq!(data["conversations"].as_array().unwrap().len(), 1);
assert_eq!(data["conversations"][0]["conversation_id"], "abc");
assert_eq!(data["conversations"][0]["is_mod"], true);
assert_eq!(
data["pagination"],
json!({"limit":2,"server_rows":2,"returned":1,"truncated":true})
);
assert_eq!(o.status.code(), Some(8));
assert_eq!(parsed(&o)["error"]["code"], "capability_unavailable");
}
#[test]
fn owner_filters_and_continuation_are_sent() {
fn list_filters_are_sent_and_search_is_literal() {
let (o, r) = fixture(
200,
"application/json",
r#"{"scope":"owner","conversations":[],"pagination":{"has_more":true,"next_offset":10}}"#,
r#"[{"conversation_id":"a","topic":"50% OFF","is_owner":true},{"conversation_id":"b","topic":"500 ideas","is_owner":true}]"#,
&[
"conversation",
"list",
"--mine",
"--recent",
"--offset",
"5",
"--limit",
"5",
"--active",
"false",
"--draft",
"true",
"--search",
"literal %",
"50% off",
"--json",
],
);
assert!(o.status.success());
assert!(r.starts_with("GET /api/v3/conversations?mine=true&limit=5&offset=5&is_active=false&is_draft=true&search=literal+%25 "));
assert_eq!(parsed(&o)["data"]["pagination"]["next_offset"], 10);
assert!(
r.starts_with("GET /api/v3/conversations?limit=100&is_active=false&is_draft=true "),
"{r}"
);
let data = &parsed(&o)["data"];
assert_eq!(data["conversations"].as_array().unwrap().len(), 1);
assert_eq!(data["conversations"][0]["conversation_id"], "a");
assert_eq!(
data["pagination"],
json!({"limit":100,"server_rows":2,"returned":1,"truncated":false})
);
}
#[test]
fn list_refuses_an_unrecognized_shape() {
for body in [r#"{"conversations":[]}"#, r#"[{"conversation_id":"x"}]"#] {
let (o, _) = fixture(
200,
"application/json",
body,
&["conversation", "list", "--json"],
);
assert_eq!(o.status.code(), Some(1), "{body}");
assert_eq!(parsed(&o)["error"]["code"], "invalid_response", "{body}");
}
}
#[test]
fn offset_is_not_an_option() {
let t = tempfile::tempdir().unwrap();
let o = run(
t.path(),
&["conversation", "list", "--offset", "5", "--json"],
);
assert_eq!(o.status.code(), Some(2));
assert_eq!(parsed(&o)["error"]["code"], "invalid_input");
}
#[test]
fn plural_conversations_is_an_alias() {
let t = tempfile::tempdir().unwrap();
let o = run(t.path(), &["conversations", "list", "--help", "--json"]);
assert!(o.status.success());
assert_eq!(parsed(&o)["ok"], true);
let (o, r) = fixture(
200,
"application/json",
"[]",
&["conversations", "list", "--json"],
);
assert!(o.status.success());
assert!(r.starts_with("GET /api/v3/conversations?limit=100 "), "{r}");
}
#[test]
fn public_id_and_token_are_sent_correctly() {
Expand Down
Loading