Security
tweet-factory stores another person’s posts (everything you liked or bookmarked) plus your own, and it holds a long-lived X refresh token. Treat the checkout like a credential.
Secrets
.env,.tokens.json, and.cloud.jsonare gitignored..tokens.jsonand.cloud.jsonare written atomically, mode0600.- When
tfupdates.env, and whensetup_x.shwrites keys, the file is mode0600. A hand-edited.envis not. Runchmod 600 .envafter editing it yourself..env.tmpand.tokens.json.tmpare gitignored. - Opening
tweets.dbfor write sets it to mode0600, including an existing file that was looser.backup.shsets mode0600on each snapshot oftweets.dbandpersona/PERSONA.md. - Do not log token or key values. The docs’ verification commands print
setormissing, not the secret. X token and API errors log the status code only. backups/contains the database and the persona. It is local on purpose. Do not point it at a public remote.
OAuth callback
tf auth listens on 127.0.0.1 for about five minutes, then stops. Set OAUTH_BIND_HOST to something else only when you must authorize from a different machine (for example a Tailscale address). The CLI prints a warning when the bind is not loopback. Register the exact OAUTH_REDIRECT_URI on the X app. State is checked; a mismatch aborts as a possible CSRF.
One refresh token, one machine
X rotates the refresh token when it is used. Two hosts running tf fetch against the same .tokens.json will invalidate each other. Symptoms: 401s, then tf auth required. Keep a single scheduler.
What is sent off the machine
The default build does not contact tweetfactory.sh. TF_LICENSE_KEY is not sent anywhere until a billing provider replaces resolveProvider() in src/entitlements/index.ts. That provider must not upload tweets.db, the persona, or OAuth tokens. See the hardening plan. tf login and tf sync are a separate opt-in. Login does not upload the database. Other network calls happen only to services you configured:
| Call | When |
|---|---|
| X API | auth, fetch, enrich of X articles |
| Your LLM | tf generate, onboard generate. The default is the Claude Code CLI already logged in on the machine. |
| Tavily | tf search, tf enrich, tf generate --timely, tf cron |
| Notion | tf notion, cron sync |
| Slack | tf notify, tf generate --post-to-slack, cron’s pending ping |
| tweetfactory.sh | tf login, tf sync push, pull, status, delete |
Cloud sync encrypts tweet text, author descriptions, article extracts, Notion notes and page text, and persona and draft bodies before they are stored. The operator who holds SYNC_ENCRYPTION_KEY can decrypt them. Ids, usernames, metrics, tags, and filenames are stored in the clear so the account page can list files. Details: Cloud sync.
tf analyze, tf flush, and tf refresh do not call an LLM. tf refresh does call the X API. tf update downloads a public release binary and does not call the X API.
Scraped text
Tweets, article extracts, Tavily results, and Notion pages are untrusted. tf generate wraps them in <reference_data> and strips a closing fence that appears inside that text. The Claude Code subprocess is started with --tools "", --disallowedTools "*,mcp__*", --strict-mcp-config, --permission-mode dontAsk, and --max-turns 1. --allowedTools only pre-approves tools, so an empty allow list is not the control. Direct API backends have no tools in this CLI either; they get a single completion.
What this program will not do
It does not post, like, or bookmark on your behalf. The OAuth scopes are read plus offline.access. Publishing a draft is a separate action you take in the X client.
Verify.
git status --ignored --short -- .env .tokens.json .cloud.json tweets.dbshows those paths ignored (once they exist).stat -c '%a' .tokens.jsonis600after a successful auth. Aftertf login,stat -c '%a' .cloud.jsonis600.