# Bluesky

Flaghand connects a Bluesky account with Bluesky's own sign-in (atproto OAuth), never an app password. Any account
connects: one Bluesky hosts, or one on its own server, with a handle or a DID. An agent starts the connect; the
account's owner signs in at their Bluesky server.

## Routes

| Route | Who | Does |
|---|---|---|
| `POST /workspaces/{workspace}/connections/bluesky/start` | `connections` scope | Starts connecting `{ "handle": "alice.bsky.social" }` (or a DID). The answer's `url` is the sign-in link, which leads to the account's Bluesky sign-in and consent page, for its owner to open once, before `expires_at` (about 5 minutes: Bluesky drops an unused sign-in then). |
| `POST /workspaces/{workspace}/connections/authorize` | owner | The same, started by the owner (`{ "platform": "bluesky", "handle": "…" }`). |
| `GET /workspaces/{workspace}/connections` | `read` | The connection: `external_id` is the account's DID, `handle` its handle. Never the tokens. |
| `POST /workspaces/{workspace}/connections/{id}/verify` | `read` | Renews the login to prove the grant works and checks the account's server still speaks for it. |
| `DELETE /workspaces/{workspace}/connections/{id}` | owner | Disconnects: ends Flaghand's session at Bluesky and deletes the stored tokens. |

## Connecting

An agent with the `connections` scope calls `POST …/connections/bluesky/start` with the account's handle and gives the
owner the `url`. Flaghand first finds the account: the handle's DID (by DNS or HTTPS), the DID document, which must
claim the handle back, the account's server and the authorization server that speaks for it. A handle that doesn't
resolve is `404 not_found` (`reason` `handle_not_found`); one whose account doesn't claim it is `400 invalid_request`
(`handle_not_claimed`). To reconnect, send `connection_id` instead of the handle.

The owner signs in on their server's own page (their password is never typed into Flaghand) and allows posting. It
asks for Bluesky's permission set for creating posts, threadgates and postgates (it can't edit or delete posts), plus
image and video uploads and the two service tokens Bluesky's video service needs:
`atproto include:app.bsky.authCreatePosts?aud=did:web:api.bsky.app%23bsky_appview blob:image/* blob:video/*
rpc:app.bsky.video.getUploadLimits?aud=* rpc:com.atproto.repo.uploadBlob?aud=*`. A server that doesn't take permission sets is asked for `atproto transition:generic`
instead. Flaghand never asks for direct messages.

When the sign-in comes back, Flaghand checks that it came from the server it started at and that the account that
signed in is the one asked for, and that its server still speaks for it. Anything else is refused and the login it got
is revoked at once. The connection is pinned to the account's DID: a handle can change, the DID can't, and reconnecting
it as another account is refused. Its tokens and its own signing key (DPoP) are stored encrypted.

Logins renew on their own (Bluesky keeps a session for up to two years). Each renewal uses a token that works once, so
Flaghand claims that token version in the database before sending it. A concurrent caller gets a retryable conflict;
it can retry after the renewal finishes. A slow request never allows another caller to reuse the token. If the renewal's
result is lost or interrupted, reconnect instead of replaying it. A delayed answer cannot restore a disconnected login
or overwrite a newer sign-in.

## Drafting

A Bluesky draft names the connection (`connection_id`) and is one of two kinds:

- `bluesky_post`: `{ "text": "…", "media": [{ "media_id": "med_…", "alt": "…" }], "card": { "url": "…", "title": "…",
  "description": "…", "image_media_id": "med_…" }, "quote_url": "https://bsky.app/profile/…/post/…", "reply_to_url":
  "…", "langs": ["en"] }`. Only `text` is needed; the rest is optional.
- `bluesky_thread`: `{ "posts": [{ "text": "…", "media": […], "card": {…} }, …], "quote_url": "…", "reply_to_url": "…",
  "langs": […] }`. Posts 2 on reply to the post before.

Bluesky's own rules apply: text up to 300 characters (graphemes) and 3,000 bytes, counted after links are shortened as
the app shortens them; up to 3 languages; one embed per post (up to 4 photos of 2 MB each, one video, or a link card
whose image is up to 1 MB, or a quote alone or with any of them). Links, mentions and hashtags are marked the way the
Bluesky app marks them. A quoted or replied-to post's own gates (who can reply, quoting off) are checked too.
`POST …/drafts/{id}/check` reports anything that would stop it posting as previewed, with the fix.

Request approval as for any draft. The approval binds the exact records: each photo's bytes, each mention's DID and the
exact version of a quoted or replied-to post. When the job runs, Flaghand writes those records, then reads each post
back from the account's server: the job's `result.ids` are the posts' `at://` URIs, `result.urls` their bsky.app links,
and `result.check` says whether each post is live and matches what was approved.

## In the desk

Settings › Connected accounts has a Bluesky block: type the handle, then Connect, and sign in on the tab that opens.
The welcome page offers Bluesky beside X and LinkedIn. New post › Bluesky drafts a post, previewed as bsky.app's
timeline draws it; a thread an agent drafted is edited post by post. The approval sheet shows the same preview. A posted draft shows its live check and a
link to the post on Bluesky.

## Video

A `bluesky_post` or a post of a `bluesky_thread` can carry one MP4 video instead of photos or a link card, with its alt
text. Bluesky allows up to 300 MB and 10 minutes; Flaghand's media library holds files up to 50 MB. Bluesky chooses
the poster frame, so a cover isn't taken, and captions aren't posted yet. The approval binds the file's SHA-256 and the
post around it. The file must still match when it's sent.

When the job runs, Flaghand asks Bluesky whether the account can upload, uploads the file, and the job stays
`processing` while Bluesky encodes and scans it, usually a few minutes. The post goes out when Bluesky has the
processed video, and read-back checks it's the approved post with Bluesky's video. A later post on the account waits for
it. If Bluesky hasn't finished after two hours, the job fails and nothing is posted.

If the account has reached Bluesky's daily video limit, the job fails with `bluesky_video_limit` and Bluesky's own
words, with nothing sent. A connection made before video existed lacks these permissions: the job is held until the
owner reconnects.

## Disconnecting

`DELETE …/connections/{id}` (owner) ends Flaghand's session at Bluesky and deletes the stored tokens at once. It's
refused while a post still needs the login. Deleting the account ends every session too. The owner can also revoke the
app in their Bluesky settings.
