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.
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 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/providersreturns available connectors, connect support, required instance/target fields, limitations, and real manifest capabilities.POST /connections/authorizeaccepts{ provider, instanceUrl? }, validates connection-management permission, returns{ redirectUrl }, and sets the callback binding cookie.GET /connections/callbackis 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-tokenaccepts{ token, channelId?, connectionId? }.connectionIdrotates the existing bot connection instead of creating another.POST /connections/:connectionId/reconnectreauthorizes the existing account and instance. A different provider identity is rejected; failed consent/exchange leaves the previous credential intact.DELETE /connections/:connectionIdlocks 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 /connectionsprobes provider identity:healthy,needs_credentials, orunavailable, 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.