T-HUB/docs/oauth-connect.md
hacklab 57762f543c feat: wire the Instagram connector into connect and publishing
The Instagram connector existed but was never registered, so Channels showed
it as "provider unavailable" and publishing could not resolve it.

- Register createInstagramConnector in the connections registry and the
  publishing registry; add the connector-runtime publishing policy
  (idempotency "none": media_publish has no key, never replay).
- The Facebook app credentials identify the Hub, not a collective: read them
  only from INSTAGRAM_CLIENT_ID / INSTAGRAM_CLIENT_SECRET. Drop the
  clientId/clientSecret connect fields and stop copying the app secret into
  each connection's vault entry. The provider is connectable only when both
  are set (connectEnabled + connectNote).
- Fail the connect when several Instagram accounts are granted instead of
  silently publishing as the first Page's account.
- Fix the connector package's pre-existing lint errors (it now ships in api).
- Tests: contract cases for env-only credentials and the multi-account
  refusal; connections integration case for provider gating and the
  Facebook dialog redirect.
- docs/oauth-connect.md: Instagram setup, callback URI and limits.

Not in this change: publishing images needs a public plaintext JPEG URL that
Meta downloads, and storage is client-side encrypted, so presigned S3 URLs
cannot serve it. Until a short-lived media endpoint exists, publishing to
Instagram fails with an explicit error. Token refresh via ig_refresh_token
on graph.facebook.com also needs checking against Facebook Login tokens.
2026-10-18 10:35:33 +00:00

9.7 KiB

Connecting platform accounts

The API resolves authorization behavior through connector methods and manifests. It currently registers Mastodon, PeerTube, Telegram, and Instagram. Other providers are not offered as connectable until a connector implements the same authorization surface.

Configuration

Environment variable Meaning
APP_URL Web origin used for the final /connections?connect=success or failure redirect. Default http://localhost:3000; set http://localhost:3001 when running the repository's usual API-on-3000/web-on-3001 setup.
API_URL Public API base. Default http://localhost:3000.
CONNECTIONS_CALLBACK_URL Optional exact callback URI override; default ${API_URL}/connections/callback. Register this exact URI with configured provider applications.
CORS_ORIGINS Comma-separated permitted browser origins. Include the web origin; cookie credentials are enabled only for these explicit origins.
REDIS_URL Pending authorization store; default redis://localhost:6379. Production should use an authenticated rediss:// URL.
SUPERPOSTER_KEK Production-required 32-byte base64 envelope encryption key. Keep it stable across replicas/restarts and secure its backups. The existing development key fallback is unsuitable for production.
MASTODON_INSTANCE_URL Instance origin to which the configured Mastodon client belongs.
MASTODON_CLIENT_ID, MASTODON_CLIENT_SECRET Optional pre-registered Mastodon application credentials, used only for MASTODON_INSTANCE_URL.
PEERTUBE_INSTANCE_URL Instance origin to which the configured PeerTube bridge client belongs.
PEERTUBE_CLIENT_ID, PEERTUBE_CLIENT_SECRET Optional credentials for that instance. Otherwise the connector discovers the local client through /api/v1/oauth-clients/local.
INSTAGRAM_CLIENT_ID, INSTAGRAM_CLIENT_SECRET The Hub's Facebook app (App ID / App Secret from developers.facebook.com → App settings → Basic). One pair per deployment; Instagram is offered as connectable only when both are set.
PEERTUBE_AUTHORIZE_PATH, PEERTUBE_TOKEN_PATH Required to enable a PeerTube redirect bridge. Both must be paths beginning with /, without a second leading slash, on the selected instance origin.

Use HTTPS and the same browser site for the web and API (for example app.example.org and api.example.org), or proxy the API under the web origin. The callback binding cookie is HttpOnly, SameSite=Lax, and Secure in production. For development, consistently use localhost or consistently use 127.0.0.1 for both web and API; mixing the two breaks browser cookie delivery.

Mastodon

Without a configured client for the selected instance, the connector registers an application using POST /api/v1/apps, with the exact callback URI and read write:statuses write:media scopes. This is the supported path for arbitrary self-hosted instances; there is no universal client secret shared by all instances. Reconnect reuses that connection's registered client. Both legs use S256 PKCE. Mastodon's OAuth documentation describes the authorization-code grant and PKCE support (Mastodon 4.3+).

PeerTube limitation and bridge contract

Stock PeerTube documents local OAuth client discovery followed by a password grant, rather than an authorization-code consent flow. The connect API never accepts account passwords, so the default PeerTube connect button is disabled with an explanation. See the official PeerTube authentication guide.

An instance administrator can enable a compatible OAuth bridge, then configure the two paths above. Its authorize endpoint must preserve state, register/validate the redirect URI, support response_type=code and S256 PKCE, and redirect back with a one-use code. Its token endpoint must validate client_id, client_secret, redirect_uri, code, and code_verifier, returning actual PeerTube API access_token and refresh_token values. Identity, refresh, and revoke then use PeerTube's native /api/v1/users/me, /api/v1/users/token, and /api/v1/users/revoke-token endpoints. These settings do not add an authorization-code grant to a stock instance, and an OIDC identity token alone is insufficient. The mock integration test proves this adapter contract; no third-party bridge is included in this change.

Instagram

Instagram connects through Facebook Login against the Hub's own Facebook app. The App ID and App Secret identify the Hub, not a collective: they are read only from INSTAGRAM_CLIENT_ID / INSTAGRAM_CLIENT_SECRET, never entered on the connect form and never written to the vault or the pending store. In the Facebook app, add the product Facebook Login for Business and register the exact CONNECTIONS_CALLBACK_URL (default ${API_URL}/connections/callback) as a Valid OAuth Redirect URI.

Each person connects their own account through Meta's consent dialog; the Hub keeps the resulting long-lived token encrypted per connection. Requirements per account: an Instagram Business or Creator account linked to a Facebook Page, and — while the app is in Development mode — a Tester role on the app. Grant exactly one Instagram account in the dialog: when several are granted the connect fails instead of guessing which one to publish as. Public use needs Meta App Review for instagram_basic, instagram_content_publish and pages_show_list.

Limitations: Instagram has no text-only posts and no binary upload — Meta downloads each JPEG from a public https URL. Media storage is client-side encrypted, so S3 presigned URLs cannot serve it; publishing images requires a short-lived plaintext media endpoint that is not part of this change. Until then publishing to Instagram fails with an explicit error. Stories are not publishable through the API; Meta caps API publishing at 25 posts per rolling 24 h per account.

Telegram

Create a bot in BotFather and enter its bot token. The connector validates it with getMe, then stores the token through the same encrypted vault envelope as OAuth credentials. Optionally supply a chat/channel ID (@channel or a numeric chat ID); add the bot to the target and grant any necessary posting rights. Identity health confirms token validity, not permission to publish to every chat. Reconnect accepts a replacement bot token for the same bot and rotates the credential row in place. Telegram's Bot API cannot revoke its own bot token: Remove clears local authorization; revoke/rotate the token through BotFather when needed.

API and storage behavior

  • GET /connections/providers returns available connectors, connect support, required instance/target fields, limitations, and real manifest capabilities.
  • POST /connections/authorize accepts { provider, instanceUrl? }, validates connection-management permission, returns { redirectUrl }, and sets the callback binding cookie.
  • GET /connections/callback is intentionally outside the bearer guard. It requires the random state and matching browser/workspace binding, rechecks the initiating user's current membership/epoch/permission, exchanges the code, validates identity, and redirects with a fixed success/failure result. Provider error messages never reach the redirect or API logs.
  • POST /connections/telegram/bot-token accepts { token, channelId?, connectionId? }. connectionId rotates the existing bot connection instead of creating another.
  • POST /connections/:connectionId/reconnect reauthorizes the existing account and instance. A different provider identity is rejected; failed consent/exchange leaves the previous credential intact.
  • DELETE /connections/:connectionId locks the connection, revokes supported provider credentials, and deletes the connection and cascaded credential row. It also removes local schedules/publication records required by existing restrictive foreign keys; published provider posts remain. The UI asks for confirmation with this consequence. If a provider is unavailable, local removal is retained for retry; already-invalid credentials can be removed.
  • GET /connections probes provider identity: healthy, needs_credentials, or unavailable, and returns capability names without credentials. Automatic token refreshes are encrypted and persisted with a compare-and-swap fence against concurrent reconnects.

Pending authorizations are random 256-bit state keys with a ten-minute Redis TTL. The Redis value is encrypted, including app secrets, PKCE verifier, initiating session context, and redirect configuration. Atomic compare-and-delete permits only one callback across replicas. Credentials use a persistent AES-256-GCM envelope in the existing credentials.ciphertext column, with its DEK wrapped by the existing KEK and authenticated tenant/handle binding. No database migration or dependency addition is needed. Audit mutations and credential writes share a tenant transaction; audits contain only the provider identifier/action, never credentials. Bot request URLs and callback URLs are excluded from HTTP tracing, and API structured logs redact token fields and request bodies/query/cookie/authorization data.

Verification

apps/api/src/connections/connections.integration.test.ts runs real Nest routes, real connector auth/identity/revoke code, a PKCE-checking mock authorization server, TTL-faithful pending storage, and encrypted connection storage through in-process HTTP. It needs no open TCP ports, PostgreSQL, or Redis. Persistence unit tests independently exercise the real SQL repository's parameterization, tenant context, revocation ordering, and ciphertext-only audit writes. Existing live database integration suites still require their documented database environment variables.