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
1 change: 1 addition & 0 deletions crates/titen-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
6 changes: 6 additions & 0 deletions crates/titen-core/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
52 changes: 52 additions & 0 deletions crates/titen-core/src/threads_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -35,6 +36,45 @@ 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<crate::models::Account> {
if is_user_id_usable(&account.user_id) {
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 ({} chars, non-numeric) — self-healing via /me",
account.username,
account.user_id.len()
);
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.
Expand Down Expand Up @@ -384,6 +424,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?;

Expand Down Expand Up @@ -446,6 +488,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?;

Expand All @@ -470,6 +514,8 @@ impl ThreadsClient {
account: &crate::models::Account,
creation_id: &str,
) -> Result<String> {
// #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
Expand Down Expand Up @@ -845,6 +891,8 @@ impl ThreadsClient {
metrics: Option<&str>,
) -> Result<Vec<InsightMetric>> {
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
Expand Down Expand Up @@ -1078,6 +1126,8 @@ impl ThreadsClient {
&self,
account: &crate::models::Account,
) -> Result<PublishingLimit> {
// #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
Expand Down Expand Up @@ -1268,6 +1318,8 @@ impl ThreadsClient {
limit: Option<u32>,
) -> Result<Vec<serde_json::Value>> {
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
Expand Down
52 changes: 52 additions & 0 deletions crates/titen-core/src/user_id_resolution.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
//! 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). Synthetic value with
// the same shape — a real token must never be embedded in tests.
assert!(!is_user_id_usable(
"THXXSYNT0HET1CEXAMPL3TOKEN0PLACEH0LD3RVALUE0ONLY0FOR0TESTS0XX"
));
}

#[test]
fn alphanumeric_ids_are_not_usable() {
assert!(!is_user_id_usable("17ab34"));
}
}
Loading