Commit 1cb4a77
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
- crates
- threads-cli/src
- commands
- threads-core/src
- threads-ingest
- src
- tests
- threads-manifest/src
- threads-provider-official/src
- threads-store/src
- docs
- plans
- manifests
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
37 | 37 | | |
38 | 38 | | |
39 | 39 | | |
40 | | - | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
41 | 43 | | |
42 | 44 | | |
43 | 45 | | |
44 | | - | |
45 | | - | |
| 46 | + | |
| 47 | + | |
46 | 48 | | |
47 | 49 | | |
48 | 50 | | |
49 | 51 | | |
50 | 52 | | |
51 | | - | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
52 | 70 | | |
53 | 71 | | |
54 | 72 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
69 | 69 | | |
70 | 70 | | |
71 | 71 | | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
72 | 77 | | |
73 | 78 | | |
74 | 79 | | |
| |||
134 | 139 | | |
135 | 140 | | |
136 | 141 | | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
137 | 176 | | |
138 | 177 | | |
139 | 178 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
4 | 4 | | |
5 | 5 | | |
6 | 6 | | |
7 | | - | |
| 7 | + | |
8 | 8 | | |
9 | 9 | | |
10 | 10 | | |
| |||
81 | 81 | | |
82 | 82 | | |
83 | 83 | | |
84 | | - | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
85 | 87 | | |
86 | 88 | | |
87 | 89 | | |
| |||
109 | 111 | | |
110 | 112 | | |
111 | 113 | | |
112 | | - | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
113 | 117 | | |
114 | 118 | | |
115 | 119 | | |
| |||
126 | 130 | | |
127 | 131 | | |
128 | 132 | | |
129 | | - | |
130 | | - | |
131 | | - | |
132 | | - | |
| 133 | + | |
133 | 134 | | |
134 | 135 | | |
135 | 136 | | |
136 | 137 | | |
137 | 138 | | |
138 | 139 | | |
139 | 140 | | |
140 | | - | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
141 | 146 | | |
142 | 147 | | |
143 | 148 | | |
| |||
180 | 185 | | |
181 | 186 | | |
182 | 187 | | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
183 | 191 | | |
184 | 192 | | |
185 | 193 | | |
| |||
218 | 226 | | |
219 | 227 | | |
220 | 228 | | |
221 | | - | |
222 | | - | |
223 | | - | |
224 | | - | |
| 229 | + | |
| 230 | + | |
225 | 231 | | |
226 | 232 | | |
227 | 233 | | |
| |||
0 commit comments