From d9e9ef2966d7925875abbaab651581f0c3c2c8a0 Mon Sep 17 00:00:00 2001 From: ajianaz Date: Fri, 11 Sep 2026 00:10:53 +0700 Subject: [PATCH 1/2] fix(accounts): self-heal unusable user_id via /me before Graph calls (#265) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What - New user_id_resolution module: is_user_id_usable() predicate (non-empty numeric string) + 4 unit tests. - ThreadsClient::ensure_resolved_user_id(): when the stored user_id is unusable (NULL/empty/token-in-id-slot — observed on the ajianaz account), resolve the real id via GET /me, persist it to the DB, and return the healed account. No-op for healthy accounts. - Wired into all six call sites that build /v1.0/{user_id}/... URLs: create_container, create_container_full, publish_container, fetch_user_insights, fetch_publishing_limit, fetch_mentions. - Store::pool() getter (private pool was unreachable cross-module). ## Why The ajianaz account carried user_id NULL for weeks because an OAuth exchange path failed to persist it. Every user-id-scoped call (mentions fetch, publishing limit, publishing itself) built URLs with the raw token as the path segment and Meta rejected them with 'Unsupported get request'. The token is valid — /me proves it — so the account heals itself on the first affected call, with zero cost for healthy accounts. Live root cause evidence: see the 2026-09-10 multi-account audit (publishing-limit + mentions fail only for ajianaz; profile via /me works). ## Testing - 4 unit tests for the usability predicate (numeric ok, empty/token/ alphanumeric rejected). - Full workspace: 251 passed / 0 failed; clippy -D warnings clean; fmt clean. - Live verification plan post-deploy: ajianaz mentions fetch + publishing-limit flip from LIMITS_FETCH_FAILED/MENTIONS_FETCH_FAILED to 200 on the first call. --- crates/titen-core/src/lib.rs | 1 + crates/titen-core/src/store.rs | 6 +++ crates/titen-core/src/threads_client.rs | 49 ++++++++++++++++++++ crates/titen-core/src/user_id_resolution.rs | 51 +++++++++++++++++++++ 4 files changed, 107 insertions(+) create mode 100644 crates/titen-core/src/user_id_resolution.rs diff --git a/crates/titen-core/src/lib.rs b/crates/titen-core/src/lib.rs index b9c1d35..f0e9fbf 100644 --- a/crates/titen-core/src/lib.rs +++ b/crates/titen-core/src/lib.rs @@ -30,6 +30,7 @@ pub mod store; pub mod threads_client; pub mod time; pub mod trend; +pub mod user_id_resolution; pub use crypto::Cipher; pub use error::{Result, TitenError}; diff --git a/crates/titen-core/src/store.rs b/crates/titen-core/src/store.rs index 6e4dba3..b0bb5cd 100644 --- a/crates/titen-core/src/store.rs +++ b/crates/titen-core/src/store.rs @@ -34,6 +34,12 @@ pub struct Store { } impl Store { + /// Read-only access to the underlying pool (used by cross-module + /// persistence helpers, e.g. user_id self-healing in the threads client). + pub fn pool(&self) -> &sqlx::SqlitePool { + &self.pool + } + /// Create a store with encryption enabled. /// /// The cipher is loaded from `TITEN_ENCRYPTION_KEY` env var. diff --git a/crates/titen-core/src/threads_client.rs b/crates/titen-core/src/threads_client.rs index 2e750fd..35750cc 100644 --- a/crates/titen-core/src/threads_client.rs +++ b/crates/titen-core/src/threads_client.rs @@ -3,6 +3,7 @@ use crate::models::{ CommentData, ContainerStatus, InsightMetric, PublishingLimit, UserInsightMetric, UserProfile, }; use crate::store::Store; +use crate::user_id_resolution::is_user_id_usable; use reqwest::Client; use tracing::{info, warn}; @@ -35,6 +36,42 @@ impl ThreadsClient { Self { http, store } } + /// Return the account with a guaranteed-usable `user_id` (#265). + /// + /// Cheap when healthy (in-memory check only). When the id is missing or + /// malformed, resolves via `GET /me`, persists the correction to the DB, + /// and returns the healed account. Callers that build + /// `/v1.0/{user_id}/...` URLs should route through this instead of using + /// `account.user_id` directly. Design & rationale: `user_id_resolution`. + pub async fn ensure_resolved_user_id( + &self, + account: &crate::models::Account, + ) -> Result { + if is_user_id_usable(&account.user_id) { + return Ok(account.clone()); + } + + warn!( + "Account @{} has unusable user_id ({:?}) — self-healing via /me", + account.username, account.user_id + ); + let (user_id, _username) = self.resolve_account(&account.access_token).await?; + + sqlx::query("UPDATE accounts SET user_id = ?1, updated_at = datetime('now') WHERE id = ?2") + .bind(&user_id) + .bind(&account.id) + .execute(self.store.pool()) + .await?; + + let mut healed = account.clone(); + healed.user_id = user_id; + info!( + "Account @{} self-healed: user_id set to {}", + healed.username, healed.user_id + ); + Ok(healed) + } + // ─── Internal Helpers ───────────────────────────────────── /// Send a request and parse JSON, checking for Threads API errors. @@ -384,6 +421,8 @@ impl ThreadsClient { body["video_url"] = serde_json::json!(url); } + // #265: self-heal a NULL/malformed user_id before building the URL. + let account = &self.ensure_resolved_user_id(account).await?; let url = format!("{THREADS_GRAPH_API}/v1.0/{}/threads", account.user_id); let resp = self.threads_post(&url, &body).await?; @@ -446,6 +485,8 @@ impl ThreadsClient { body["location_id"] = serde_json::json!(loc_id); } + // #265: self-heal a NULL/malformed user_id before building the URL. + let account = &self.ensure_resolved_user_id(account).await?; let url = format!("{THREADS_GRAPH_API}/v1.0/{}/threads", account.user_id); let resp = self.threads_post(&url, &body).await?; @@ -470,6 +511,8 @@ impl ThreadsClient { account: &crate::models::Account, creation_id: &str, ) -> Result { + // #265: self-heal a NULL/malformed user_id before building the URL. + let account = &self.ensure_resolved_user_id(account).await?; let url = format!( "{THREADS_GRAPH_API}/v1.0/{}/threads_publish", account.user_id @@ -845,6 +888,8 @@ impl ThreadsClient { metrics: Option<&str>, ) -> Result> { let fields = metrics.unwrap_or("likes,replies,reposts,quotes,views,shares"); + // #265: self-heal a NULL/malformed user_id before building the URL. + let account = &self.ensure_resolved_user_id(account).await?; let url = format!( "{THREADS_GRAPH_API}/v1.0/{threads_post_id}/insights?metric={fields}&access_token={}", account.access_token @@ -1078,6 +1123,8 @@ impl ThreadsClient { &self, account: &crate::models::Account, ) -> Result { + // #265: self-heal a NULL/malformed user_id before building the URL. + let account = &self.ensure_resolved_user_id(account).await?; let url = format!( "{THREADS_GRAPH_API}/v1.0/{}/threads_publishing_limit?access_token={}", account.user_id, account.access_token @@ -1268,6 +1315,8 @@ impl ThreadsClient { limit: Option, ) -> Result> { let limit_val = limit.unwrap_or(25); + // #265: self-heal a NULL/malformed user_id before building the URL. + let account = &self.ensure_resolved_user_id(account).await?; let url = format!( "{THREADS_GRAPH_API}/v1.0/{}/threads?where=MENTION&fields=id,text,username,timestamp,media_type,permalink&limit={limit_val}&access_token={}", account.user_id, account.access_token diff --git a/crates/titen-core/src/user_id_resolution.rs b/crates/titen-core/src/user_id_resolution.rs new file mode 100644 index 0000000..76623d6 --- /dev/null +++ b/crates/titen-core/src/user_id_resolution.rs @@ -0,0 +1,51 @@ +//! Self-healing user_id resolution (#265). +//! +//! Some accounts carry a NULL/malformed `user_id` in the DB (an OAuth +//! exchange path historically failed to persist it — the ajianaz account +//! shipped that way for weeks). Every Graph URL is built as +//! `/v1.0/{user_id}/...`, so a NULL id makes Titen use the raw access token +//! as the path segment and Meta rejects with "Unsupported get request". The +//! token itself is fine — which `GET /me` proves — so the account can heal +//! itself: resolve the real id once, persist it, and never pay the cost +//! again. +//! +//! The actual `ensure_resolved_user_id` implementation lives in +//! `threads_client` (same module as the private `store` field it persists +//! through). This module holds the usability predicate and its tests. + +/// A user_id is usable when it is a non-empty numeric string. Meta ids are +/// numeric; the observed failure mode stores the raw TOKEN in the id slot, +/// which is base64-ish and always contains non-digits. +pub fn is_user_id_usable(user_id: &str) -> bool { + !user_id.is_empty() && user_id.chars().all(|c| c.is_ascii_digit()) +} + +#[cfg(test)] +mod tests { + use super::is_user_id_usable; + + #[test] + fn numeric_ids_are_usable() { + assert!(is_user_id_usable("34327938090154478")); + assert!(is_user_id_usable("1")); + } + + #[test] + fn empty_is_not_usable() { + assert!(!is_user_id_usable("")); + } + + #[test] + fn raw_token_as_id_is_not_usable() { + // The observed production failure mode: the token stored in the + // user_id slot (ajianaz account, 2026-09-10). + assert!(!is_user_id_usable( + "THAASBUF8ygJdBYmJZAb0RfMFFQemdLQjNueG44RS1EWVJFMm9mbzBmVlVaalJETV94TkNk" + )); + } + + #[test] + fn alphanumeric_ids_are_not_usable() { + assert!(!is_user_id_usable("17ab34")); + } +} From 361a90511828a7e2c7d826d5c2a67811fd4ddf42 Mon Sep 17 00:00:00 2001 From: ajianaz Date: Fri, 11 Sep 2026 00:14:18 +0700 Subject: [PATCH 2/2] fix(security): never log the token-mirrored user_id; synthesize test value --- crates/titen-core/src/threads_client.rs | 7 +++++-- crates/titen-core/src/user_id_resolution.rs | 5 +++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/titen-core/src/threads_client.rs b/crates/titen-core/src/threads_client.rs index 35750cc..a5e83a9 100644 --- a/crates/titen-core/src/threads_client.rs +++ b/crates/titen-core/src/threads_client.rs @@ -51,9 +51,12 @@ impl ThreadsClient { return Ok(account.clone()); } + // Do NOT log the stored value — in the observed failure mode it IS + // the raw access token (mirrored into the user_id slot). warn!( - "Account @{} has unusable user_id ({:?}) — self-healing via /me", - account.username, account.user_id + "Account @{} has unusable user_id ({} chars, non-numeric) — self-healing via /me", + account.username, + account.user_id.len() ); let (user_id, _username) = self.resolve_account(&account.access_token).await?; diff --git a/crates/titen-core/src/user_id_resolution.rs b/crates/titen-core/src/user_id_resolution.rs index 76623d6..af41f24 100644 --- a/crates/titen-core/src/user_id_resolution.rs +++ b/crates/titen-core/src/user_id_resolution.rs @@ -38,9 +38,10 @@ mod tests { #[test] fn raw_token_as_id_is_not_usable() { // The observed production failure mode: the token stored in the - // user_id slot (ajianaz account, 2026-09-10). + // user_id slot (ajianaz account, 2026-09-10). Synthetic value with + // the same shape — a real token must never be embedded in tests. assert!(!is_user_id_usable( - "THAASBUF8ygJdBYmJZAb0RfMFFQemdLQjNueG44RS1EWVJFMm9mbzBmVlVaalJETV94TkNk" + "THXXSYNT0HET1CEXAMPL3TOKEN0PLACEH0LD3RVALUE0ONLY0FOR0TESTS0XX" )); }