Skip to content

Commit 1cb4a77

Browse files
authored
feat(delete): time-windowed remote delete for posts and replies (#2)
* docs: add canonical delete-feature plan Locks scope (delete only, archive dropped — Meta exposes no remote archive endpoint for root posts), endpoints (DELETE /v1.0/{id} + threads_delete scope, 100/24h cap), dry-run UX, time-window semantics on posts.created_at, error model, manifest [[actions]] type, and the end-to-end CLI workflow. Used as the binding spec for parallel implementation teams. * feat(manifest): add [[actions]] write-op entries for DELETE The existing manifest only modeled GET shapes ([[objects]] for single resources, [[edges]] for paginated collections). Write ops need their own type so the schema distinguishes them, exposes per-action OAuth permission, and carries a documented flag plus rate_limit_per_day. Adds two entries: - post/delete -> DELETE /v1.0/{post-id}, threads_delete, documented - reply/delete -> DELETE /v1.0/{reply-id}, threads_delete, undocumented * feat(core): add Error::NotSupported variant Distinct from network/auth/parse errors so callers can route to a clear 'this provider can't do that' message. Used by the default trait impls of delete_post / delete_reply on providers that don't implement writes (e.g. the experimental web adapter). * feat(core): add Provider::delete_post and delete_reply Both methods carry default impls returning Error::NotSupported, which keeps the trait object-safe and lets the experimental web provider stay read-only without extra plumbing. Reply deletion is documented in-trait as undocumented-by-Meta-for-replies; replies are media objects so DELETE /{id} should work but verify on a test reply. * feat(provider-official): add HttpClient::delete_json Mirrors get_json_value's URL building, retry/backoff, x-app-usage warning, and 401/403/404/429/5xx mapping. The single deliberate difference: empty 2xx response bodies return Value::Null instead of erroring (Threads API returns no JSON body on successful DELETE). * feat(provider-official): implement delete_post and delete_reply Looks up the action path from the manifest (post/delete and reply/delete entries), substitutes the id placeholder, and issues DELETE via HttpClient::delete_json. Errors propagate via threads_core::Error so the CLI can render them uniformly. * feat(auth): request and persist threads_delete OAuth scope Adds threads_delete to DEFAULT_SCOPES so every login is delete-capable. Token grows a granted_scopes: Option<Vec<String>> field (serde-default for legacy token compatibility), populated at login time with the exact scopes we requested. Adds token_has_scope(token, scope) with strict semantics: granted_scopes = None reads as missing the scope. This is intentional for write scopes — those were added in this same release, so a None token by definition does not have them. The CLI surfaces a clear 'run auth login' instead of letting Meta return an opaque 403. The status command now prints the recorded scopes alongside expiry. * feat(store): add deletions audit + time-window query helpers Migration v3 introduces a strictly-additive deletions table tracking every delete attempt (post_id, kind, deleted_at, success, error) with indexes on deleted_at and post_id. Used by the CLI's pre-flight rate-limit gate so the 100/24h cap is auditable across processes. New public helpers: - posts_in_window(author, after, before, kind, limit) — filters by posts.created_at and PostKind::{Post,Reply}; after is inclusive, before is exclusive, NULL created_at rows excluded. - delete_post(id) — hard-delete in a transaction. media/urls/ mentions/raw_payloads cascade via FK. The edges table has no FK to posts, so we explicitly DELETE edges in BOTH directions (from_id = id OR to_id = id) inside the same transaction; otherwise stale edges would orphan the recursive thread CTE. - record_deletion(id, kind, success, error) — append to audit table; never fails the caller (logs and swallows on insert error, because losing audit must not abort actual deletes). - deletions_in_last_24h() — count successful deletes in the 24h sliding window for the rate-limit gate. - oldest_deletion_in_last_24h() — earliest counted timestamp so the CLI can render 'quota resets at <oldest + 24h>'. 20 unit tests cover migration, window filtering by kind and time, delete idempotency, edge cleanup in both directions, audit write-on-failure, and the 24h sliding-window math. * feat(cli): add delete posts and delete replies subcommands Usage: threads-cli delete posts [--before <date>] [--after <date>] [--apply] [--limit N] threads-cli delete replies [--before <date>] [--after <date>] [--apply] [--limit N] [--yes-undocumented] Default behavior is DRY-RUN: prints up to 10 sample candidates with created_at + text snippet, then a 'Run with --apply to actually delete' hint and the 100/24h rate-limit reminder. Changes nothing. With --apply: - Validates the loaded token has threads_delete scope; bails cleanly with 'run auth login' guidance otherwise. - For replies, prompts for interactive confirmation that the user accepts the undocumented endpoint, unless --yes-undocumented is passed; on a non-TTY without that flag, refuses cleanly. - Pre-flight rate-limit check: refuses if there are already 100 successful deletes in the last 24h, surfacing the timestamp at which the quota will reset (oldest counted deletion + 24h). - Iterates respecting --limit and the remaining quota, sleeping 100ms between calls. Per-id failures are logged and the loop continues; on Error::RateLimit the batch stops cleanly. - Records every attempt in the deletions audit table and prints a final summary (deleted, failed, remaining_quota_24h). Refuses to run without at least one of --before / --after to avoid catastrophic 'delete everything' invocations. --before / --after accept either RFC 3339 (2025-01-15T00:00:00Z) or bare ISO date (2025-01-15 → midnight UTC). * docs: document delete dataflow and advertise delete commands README gains a 'Commands' section that splits read-only ingest/query from destructive remote ops, with a note that --apply is required to actually delete and that archive is intentionally absent (Meta does not expose it). Points readers at docs/plans/delete.md for the design. architecture.md gains: - A 'Data flow (delete)' section walking the 9-step CLI pipeline (parse window -> token scope check -> fetch_me -> store query -> dry-run -> rate-limit gate -> per-id loop -> audit -> summary). - A 'Manifest action types' section documenting the new [[actions]] entry alongside existing [[objects]] and [[edges]]. * chore: apply cargo fmt --all to entire workspace Pure formatting output of `cargo fmt --all`. No semantic changes — `git diff -w` reports 229 lines vs the unfiltered 230 (the lone non-whitespace delta is a trailing-comma/newline normalization). CI runs `cargo fmt --all -- --check` which now exits clean. Single atomic commit because rustfmt output cannot be meaningfully split: splitting would mean some files pass --check and others don't, defeating the purpose. * fix(security): redact access_token / client_secret from logged HTTP bodies CWE-532 (cleartext logging of sensitive information). Addresses the CodeQL pattern that this PR's diff would have triggered: - Every request through HttpClient appends `?access_token=<bearer>`. - On non-2xx responses, Meta sometimes echoes the request URL (with the bearer) or includes OAuth context inside the JSON body. - We forwarded that body verbatim into Error::Auth / Error::NotFound / Error::Network / Error::Other, where it then surfaces in: * tracing::* operator logs * the CLI's `eprintln!('failed to delete {}: {err}')` on stderr * the new `deletions` audit table's `error` column The OAuth token-exchange path in auth.rs::parse_token_response had the same issue at higher severity — the success-shaped body LITERALLY contains the freshly-minted access_token. Adds crates/threads-provider-official/src/redact.rs with a single `pub(crate) fn redact(s: &str) -> String` that replaces sensitive values with '[REDACTED]' across three observed shapes: 1. URL query: `?access_token=...&...` 2. JSON: `"access_token":"..."` (with optional whitespace) 3. Form body: `access_token=...&client_secret=...&code=...` Sensitive keys: access_token, client_secret, refresh_token, code. Wires redact() into: - HttpClient::get_json_value (pre-existing 401/403/404/5xx/_ paths) - HttpClient::delete_json (mirrors the same paths added in this PR) - auth::parse_token_response (both error and parse-error formatting; raw body still parsed by serde_json so tokens reach TokenStore as intended — only the human-readable error path is sanitized) 10 unit tests cover URL / JSON / form shapes, whitespace tolerance, idempotency, an attacker-shaped Meta OAuthException with an echoed URL, and the negative case ("code":190 numeric value MUST NOT be touched — only string values can leak).
1 parent 5a5a32d commit 1cb4a77

33 files changed

Lines changed: 2259 additions & 156 deletions

File tree

README.md

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,18 +37,36 @@ cargo build --workspace
3737
cargo test --workspace
3838
```
3939

40-
## Planned commands (v1, read-only)
40+
## Commands
41+
42+
Read-only ingest + query (always safe):
4143

4244
```
4345
threads-cli init
44-
threads-cli auth login | status
45-
threads-cli ingest me | thread <post_id>
46+
threads-cli auth login | status | logout
47+
threads-cli ingest me | thread <post_id> | engagement [--depth N]
4648
threads-cli show <post_id> [--thread]
4749
threads-cli search "<query>"
4850
threads-cli export --format json|jsonl|csv
4951
```
5052

51-
Publishing (`threads_publish`), multi-account, and the private
53+
Destructive remote ops (dry-run by default; `--apply` actually performs the
54+
delete via Meta's `DELETE /v1.0/{id}` endpoint):
55+
56+
```
57+
threads-cli delete posts [--before <date>] [--after <date>] [--apply] [--limit N]
58+
threads-cli delete replies [--before <date>] [--after <date>] [--apply] [--limit N] [--yes-undocumented]
59+
```
60+
61+
Filtering uses `posts.created_at` from the local store; `--before`/`--after`
62+
accept either RFC 3339 (`2025-01-15T00:00:00Z`) or bare ISO date
63+
(`2025-01-15`). The Threads API enforces a hard cap of 100 deletions per
64+
24h; `delete` refuses cleanly when the cap is reached and reports when the
65+
quota will reset. See [`docs/plans/delete.md`](docs/plans/delete.md) for the
66+
full design.
67+
68+
Publishing (`threads_publish`), `archive` (Meta does not expose a remote
69+
archive endpoint for root posts), multi-account, and the private
5270
`threads.net/api/graphql` adapter are deferred past v1.
5371

5472
## License

crates/threads-cli/src/cli.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,11 @@ pub enum Command {
6969

7070
/// Export records from the store.
7171
Export(ExportArgs),
72+
73+
/// Delete posts or replies on Threads (remote, irreversible).
74+
/// Default is DRY-RUN; pass --apply to actually delete.
75+
#[command(subcommand)]
76+
Delete(DeleteCommand),
7277
}
7378

7479
#[derive(Debug, clap::Args)]
@@ -134,6 +139,40 @@ pub struct ExportArgs {
134139
pub out: Option<PathBuf>,
135140
}
136141

142+
#[derive(Debug, Subcommand)]
143+
pub enum DeleteCommand {
144+
/// Delete top-level posts authored by you.
145+
Posts(DeleteArgs),
146+
/// Delete replies authored by you.
147+
Replies(DeleteArgs),
148+
}
149+
150+
#[derive(Debug, clap::Args)]
151+
pub struct DeleteArgs {
152+
/// Only consider posts created STRICTLY BEFORE this time (RFC 3339 or YYYY-MM-DD).
153+
#[arg(long)]
154+
pub before: Option<String>,
155+
156+
/// Only consider posts created AT OR AFTER this time (RFC 3339 or YYYY-MM-DD).
157+
#[arg(long)]
158+
pub after: Option<String>,
159+
160+
/// Cap the number of candidates considered. Defaults to no cap, but the
161+
/// 100/24h rate limit always applies on --apply.
162+
#[arg(long)]
163+
pub limit: Option<usize>,
164+
165+
/// Actually perform the delete. Without this flag, prints what WOULD
166+
/// be deleted and changes nothing.
167+
#[arg(long)]
168+
pub apply: bool,
169+
170+
/// Skip the "this endpoint is undocumented for replies" warning prompt.
171+
/// Only relevant for `delete replies`.
172+
#[arg(long)]
173+
pub yes_undocumented: bool,
174+
}
175+
137176
#[cfg(test)]
138177
mod tests {
139178
use super::*;

crates/threads-cli/src/commands/auth.rs

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use std::{
44
path::Path,
55
};
66

7-
use anyhow::{anyhow, Context, Result};
7+
use anyhow::{Context, Result, anyhow};
88
use threads_provider_official::{
99
auth::{self, CallbackServer, DEFAULT_SCOPES},
1010
token_store::{Token, TokenStore},
@@ -81,7 +81,9 @@ async fn login_local_listener(
8181
println!("Opening browser to authorize threads-cli...");
8282
println!("If it does not open, visit this URL manually:");
8383
println!(" {url}");
84-
let _ = std::process::Command::new("open").arg(url.as_str()).status();
84+
let _ = std::process::Command::new("open")
85+
.arg(url.as_str())
86+
.status();
8587

8688
let code = server
8789
.accept_code(state)
@@ -109,7 +111,9 @@ async fn login_manual_paste(
109111
address bar and paste it here. (State to match: {state})\n"
110112
);
111113

112-
let _ = std::process::Command::new("open").arg(url.as_str()).status();
114+
let _ = std::process::Command::new("open")
115+
.arg(url.as_str())
116+
.status();
113117

114118
print!("Paste URL or code: ");
115119
io::stdout().flush()?;
@@ -126,18 +130,19 @@ async fn login_manual_paste(
126130
finish_login(provider_cfg, &code).await
127131
}
128132

129-
async fn finish_login(
130-
provider_cfg: &threads_provider_official::Config,
131-
code: &str,
132-
) -> Result<()> {
133+
async fn finish_login(provider_cfg: &threads_provider_official::Config, code: &str) -> Result<()> {
133134
let short = auth::exchange_code(provider_cfg, code)
134135
.await
135136
.map_err(|e| anyhow!("exchange code: {e}"))?;
136137
let long = auth::upgrade_to_long_lived(provider_cfg, &short.access_token)
137138
.await
138139
.map_err(|e| anyhow!("upgrade to long-lived: {e}"))?;
139140

140-
let token = Token::new(long.access_token, long.expires_in);
141+
let token = Token::new(
142+
long.access_token,
143+
long.expires_in,
144+
Some(DEFAULT_SCOPES.iter().map(|s| s.to_string()).collect()),
145+
);
141146
TokenStore::new()
142147
.save(&token)
143148
.map_err(|e| anyhow!("save token: {e}"))?;
@@ -180,6 +185,9 @@ fn status() -> Result<()> {
180185
if let Some(exp) = t.expires_in {
181186
println!("expires_in: {exp}s");
182187
}
188+
if let Some(scopes) = t.granted_scopes.as_ref() {
189+
println!("scopes: {}", scopes.join(","));
190+
}
183191
println!("expired: {}", t.is_expired());
184192
}
185193
None => println!("no token; run `threads-cli auth login`"),
@@ -218,10 +226,8 @@ mod tests {
218226

219227
#[test]
220228
fn parses_full_redirect_url() {
221-
let (code, state) = parse_code_from_input(
222-
"https://example.com/cb?code=AQx123&state=abc&extra=1",
223-
)
224-
.unwrap();
229+
let (code, state) =
230+
parse_code_from_input("https://example.com/cb?code=AQx123&state=abc&extra=1").unwrap();
225231
assert_eq!(code, "AQx123");
226232
assert_eq!(state.as_deref(), Some("abc"));
227233
}

0 commit comments

Comments
 (0)