# Cloud sync

Local files are the default. `tf` does not upload anything until you run `tf login` and then `tf sync push`. Skip both commands and the checkout never contacts tweetfactory.sh.

The copy lives on the [tweetfactory.sh](https://tweetfactory.sh) account you sign into with X. The account page at [/account/](https://tweetfactory.sh/account/) shows the synced persona and drafts, and it can export or delete that copy.

## Link a checkout

```bash
tf login
```

`tf login` starts a device-code flow. It prints a short code (`ABCD-EFGH`) and a URL such as `https://tweetfactory.sh/account/?code=ABCD-EFGH`. Open that URL in a browser where you are signed in with X, approve the code, and return to the terminal. The CLI writes `.cloud.json` (mode `0600`) and stops. No tweets, persona, or drafts are sent by login.

The code expires in about 10 minutes. The saved link lasts about 90 days. Run `tf login` again when it expires.

Origin, first match wins: `--origin`, then `TF_CLOUD_ORIGIN`, then the origin already stored in `.cloud.json`, then `https://tweetfactory.sh`.

## Push, pull, status, delete

```bash
tf sync push
tf sync pull
tf sync pull --replace
tf sync status
tf sync status --json
tf sync delete --yes
```

`push` uploads the current local snapshot:

- `tweets.db` rows: authors, tweets (likes, bookmarks, your posts, metrics), article text, Notion tags
- `persona/PERSONA.md`
- `drafts/*.md`

A push replaces the previous remote snapshot. An empty checkout still commits, which clears the remote copy. The previous snapshot stays until the commit succeeds. A push that exceeds the quota is rejected and leaves the last successful snapshot in place.

`pull` writes remote rows into the local database and writes persona and draft files. A local file newer than the remote copy is kept. `pull --replace` clears local sync tables first and deletes local persona or draft files the remote snapshot does not contain.

`status` prints bytes stored, row counts, and the quota. With no `.cloud.json` it prints that the checkout is not linked. `delete --yes` removes the remote copy and the device token. It does not delete local files and it does not delete the X sign-in on tweetfactory.sh.

> **Verify.** After `tf login`, `test -f .cloud.json` and `stat -c '%a' .cloud.json` is `600`. `tf sync status` prints a quota line only after a push. `git check-ignore -v .cloud.json` matches an ignore rule.

## What is encrypted

Encryption is server-side AES-GCM with `SYNC_ENCRYPTION_KEY`. It is not end-to-end: the operator who holds that key can decrypt. Losing the key makes stored ciphertext unreadable. The key is separate from `SESSION_SECRET`.

Encrypted before write: tweet text, author descriptions, article extracts, Notion notes and page text, persona bodies, and draft bodies. Device tokens are stored as SHA-256 hashes. The access token itself is sealed only until the CLI polls it once.

Left queryable: ids, usernames, public metrics, Notion tags, and filenames. The account page lists drafts by filename and shows persona and draft text after the server decrypts them for the signed-in user.

Rows are keyed by the X user id from the device token or the session. A request cannot name another user’s id.

## Storage

Structured rows go to Postgres (`DATABASE_URL`, Neon or PlanetScale). That holds authors, tweets, articles, Notion pages, device codes, token hashes, and a per-user usage row. Postgres fits relational keys, a generation-scoped replace, and a byte/row quota checked inside the write transaction.

Persona and draft files go to Vercel Blob (`BLOB_READ_WRITE_TOKEN`) as private ciphertext. Those bodies are files, not rows you filter in SQL.

Upstash Redis still works for sign-in sessions when `DATABASE_URL` is unset. Cloud sync refuses to start without Postgres, a Blob token, and `SYNC_ENCRYPTION_KEY`. Redis is a poor fit for tens of thousands of tweet rows.

The usage table `tf_sync_usage` records `bytes_stored`, `rows_synced`, last push, and last pull. Nullable `quota_bytes` and `quota_rows` columns exist so a later billing change can override the env quota per user. This release does not write those columns.

Default quota: 50 MiB (`52428800` bytes) and 50,000 rows. Optional env `SYNC_QUOTA_BYTES` and `SYNC_QUOTA_ROWS` raise or lower that ceiling (caps at 5,000,000,000 bytes and 5,000,000 rows). A push over the quota returns HTTP 402 and stores nothing from that attempt.

## Account page

While signed in at [tweetfactory.sh/account](https://tweetfactory.sh/account/):

- Approve the code from `tf login`.
- Read the synced persona and drafts.
- **Export** downloads `tweetfactory-sync.json` (the committed snapshot). Very large libraries return an error instead of a truncated file.
- **Delete synced data** removes the cloud copy and device tokens. The X session remains. Local files on your machine stay.

**Delete account** on the score page removes the session, saved score drafts, and the synced copy when sync is configured. `tf sync delete` does not do that.

Provisioning for the site owner is in the repository `web/README.md`. The CLI only needs `TF_CLOUD_ORIGIN` when the API is not `https://tweetfactory.sh`.
