# Protocol Operations Spec

This table is the source of truth for what `ProtocolAdapter` operations do
on each protocol. When you add an operation, add a row. When an upstream
server changes the endpoint, edit the row and the adapter together.

This file is checked in (unlike `temp/`) — it ships in the AGPL source
archive and is the artifact an on-call engineer reads when a protocol
divergence breaks something.

---

## Account lookup — `lookupAccount(query)`

| | Pixelfed | Mastodon |
|---|---|---|
| Local handle | `GET /api/v1.1/accounts/username/{localPart}` | `GET /api/v1/accounts/lookup?acct={localPart}` |
| Remote handle, cached on instance | (no `lookup` endpoint — fall through to search) | `GET /api/v1/accounts/lookup?acct={localPart}@{host}` |
| Remote handle, uncached | `GET /api/v2/search?q={localPart}@{host}&resolve=true&type=accounts&limit=1` | `GET /api/v2/search?q={localPart}@{host}&resolve=true&type=accounts&limit=1` |
| Auth required | Yes | Yes (`resolve=true` requires `read:search`) |
| Response shape | Pixelfed-extended `Account` (with `_pe=1`) | Mastodon `Account` |
| Empty / missing | `accounts/username/` returns `[]` on miss → mapped to `not-found` | `lookup` returns 404 on miss; search returns `accounts: []` |
| Diverges? | Pixelfed has no `lookup?acct=` endpoint, so remote-handle local lookup is impossible. | Mastodon `lookup` is the cheap path; `search?resolve=true` triggers webfinger and is rate-limited (~5/min/token on mastodon.social). |
| Notes | — | `resolve=true` warms the local-instance cache; subsequent calls hit the lookup path. |

## Hashtag info — `fetchHashtagInfo(name)`

| | Pixelfed | Mastodon |
|---|---|---|
| Endpoint | `GET /api/v1/tags/{encodeURIComponent(name)}` (with `_pe=1` → extended `count` field) | `GET /api/v1/tags/{encodeURIComponent(name)}` |
| Name format | Bare, no `#`; `encodeURIComponent` applied by adapter | Bare, no `#`; must match `/\A[[:word:]]+\z/`; `encodeURIComponent` applied by adapter |
| 422 causes | Rare; usually empty name | Sent with leading `#`, URL-encoded `%23`, or chars outside `[[:word:]]` (most ASCII punctuation; Unicode word chars like hiragana are accepted) |
| Auth | Required | Required |

## Hashtag timeline — `fetchHashtagFeed(name, opts)`

| | Pixelfed | Mastodon |
|---|---|---|
| Endpoint | `GET /api/v1/timelines/tag/{encodeURIComponent(name)}` | `GET /api/v1/timelines/tag/{encodeURIComponent(name)}` |
| Pagination | `?max_id=...` body cursor; adapter omits when undefined | `?max_id=...` body cursor (also Link-header); adapter omits when undefined |
| `only_media` default | Adapter sends explicit value matching caller's `onlyMedia` opt | Adapter sends explicit value (Mastodon's default is `false`; adapter sends `true` for the photo-grid surface) |
| Photo filter | Client-side: `pf_type === 'photo' && media_attachments.length` | Client-side: `media_attachments.some(m => m.type === 'image' \|\| 'gifv')` |
| Sensitivity filter | Client-side: `!sensitive` (when `filterSensitive`) | Client-side: `!sensitive` (when `filterSensitive`) |
| First-page bug history | Both servers tolerate `?max_id=undefined` but Mastodon refuses to advance past the unparseable cursor — adapter must omit the param on first page | Same |

## Hashtag related — `fetchHashtagRelated(name)`

| | Pixelfed | Mastodon |
|---|---|---|
| Endpoint | `GET /api/v1/tags/{encodeURIComponent(name)}/related` | — (no equivalent) |
| Response | `Array<Tag>` (`{ name, url }` shape) | Adapter returns `[]` so UI surfaces degrade without branching |
| Notes | Pixelfed-extended endpoint; Halflight UI is the only consumer. | Mastodon clients use trends/discovery for the "related hashtags" surface — out of scope for v1. |

## Search accounts — `searchAccounts(query, opts)`

| | Pixelfed | Mastodon |
|---|---|---|
| Endpoint | `GET /api/v2/search?type=accounts&q={query}&resolve={0|1}&limit={limit}` | `GET /api/v2/search?type=accounts&q={query}&resolve={0|1}&limit={limit}` |
| `resolve=true` semantics | Treated as best-effort hint (Pixelfed federates handles via search regardless) | Triggers webfinger + ActivityPub `inbox` discovery for unknown accounts; rate-limited |
| Auth | Required | Required (`resolve=true` requires `read:search` scope) |
| Response | `{ accounts: Account[], statuses: [], hashtags: [] }` envelope; adapter unwraps `accounts` | Same envelope |

## Notification unread count — `getNotificationUnreadCount()`

| | Pixelfed | Mastodon (≥ 4.3) |
|---|---|---|
| Endpoint | `GET /api/v1/notifications?min_id={marker}&limit=40` (marker derived from `/api/v1/markers`) | `GET /api/v1/notifications/unread_count` |
| Response shape | Array; adapter returns `length` (caps at 40 by design) | `{ count: N }` |
| Notes | When no marker exists, adapter returns 0 so a fresh install doesn't flash a huge badge. | Returns the server-filter-respecting count in a single round-trip. |

---

## Operations explicitly NOT in the adapter

Per `PROTOCOL_FIREWALL_PLAN.md` §3.3 and §6 (Phase 3 audit). Listing them
here so the next reader doesn't accidentally promote them:

- **Status CRUD / actions** (`/api/v1/statuses/*`, favourite/unfavourite,
  bookmark/unbookmark, follow/unfollow, `verify_credentials`,
  `update_credentials`): same wire shape on both. Stay in
  `src/lib/api/shared.ts` with `ContextForActiveAccount()`.
- **`getStatusContextById`** — same shape; the `Mastodon-Async-Refresh`
  header path is implemented behind the wire by both servers.
- **`getAccountStatusesById`** — same shape; the `only_media` semantics
  differ but the caller-side filter at
  `src/lib/queries/posts.ts:156` is already gated on
  `accountType === 'pixelfed'`.
- **`getCustomEmojis`** — same shape; Pixelfed emits extra `pf_*` fields
  that consumers ignore.

## Operations only one protocol has — stay in their respective module

These are not adapter candidates; they're feature-gated at the UI layer
(`useFeature(...)`, `accountType === 'mastodon'` predicates).

- Pixelfed: Stories, Collections, Discover, `api/v1.1/direct/*` DMs,
  Compose Settings, RSVP, Admin, `api/v1.1/push/*`, Newsroom.
- Mastodon: Lists, `api/v2/notifications` grouped (when surfaced through
  the protocol-specific tab), `api/v1/conversations`.

---

## Process

- **When you add an adapter operation:** add a row above with the per-protocol
  wire shape, auth requirements, response envelope, and any known
  divergences. Cross-reference the SPEC.md row in the adapter implementation
  if helpful.
- **When an upstream server renames an endpoint:** edit the row and the
  adapter together — the spec is what tells a future maintainer whether the
  rename was protocol-wide or fork-only.
- **Fixture refresh:** the `__tests__/protocol/fixtures/` snapshot is pinned
  live from a test Pixelfed instance + `mastodon.social`. Refresh quarterly
  (or whenever a row above changes). Process:
  1. `curl -H "Authorization: Bearer $TOKEN" ...` the documented endpoint
     for each protocol and dump the response to the matching fixture file.
  2. Re-run `npm test`.
  3. If the response shape genuinely changed (a new field, a different
     envelope), update the adapter and the SPEC row in the same commit.
