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.
This commit is contained in:
hacklab 2026-10-15 14:15:03 +02:00
parent a6843e904b
commit 80aa198843
9 changed files with 127 additions and 78 deletions

View file

@ -13,6 +13,7 @@
"dependencies": { "dependencies": {
"@tactical-media-broadcast-hub/connector-sdk": "workspace:*", "@tactical-media-broadcast-hub/connector-sdk": "workspace:*",
"@tactical-media-broadcast-hub/connector-telegram": "workspace:*", "@tactical-media-broadcast-hub/connector-telegram": "workspace:*",
"@tactical-media-broadcast-hub/connector-instagram": "workspace:*",
"@tactical-media-broadcast-hub/core": "workspace:*", "@tactical-media-broadcast-hub/core": "workspace:*",
"@aws-sdk/client-s3": "^3.1146.0", "@aws-sdk/client-s3": "^3.1146.0",
"@aws-sdk/s3-request-presigner": "^3.1146.0", "@aws-sdk/s3-request-presigner": "^3.1146.0",

View file

@ -9,6 +9,8 @@ import { ConnectorRegistry } from "@tactical-media-broadcast-hub/connector-sdk";
import { createMastodonConnector } from "../../../../packages/connector-mastodon/src/index.js"; import { createMastodonConnector } from "../../../../packages/connector-mastodon/src/index.js";
import { createTelegramConnector } from "@tactical-media-broadcast-hub/connector-telegram"; import { createTelegramConnector } from "@tactical-media-broadcast-hub/connector-telegram";
import { createPeerTubeConnector } from "../../../../packages/connector-peertube/src/index.js"; import { createPeerTubeConnector } from "../../../../packages/connector-peertube/src/index.js";
import { createInstagramConnector } from "@tactical-media-broadcast-hub/connector-instagram";
import { connectionRegistry } from "./connector-registry.js";
import { Vault, type SealedCredential } from "../vault/vault.js"; import { Vault, type SealedCredential } from "../vault/vault.js";
import type { SessionPayload } from "../auth/session.js"; import type { SessionPayload } from "../auth/session.js";
import { RbacGuard } from "../rbac/rbac.guard.js"; import { RbacGuard } from "../rbac/rbac.guard.js";
@ -105,12 +107,13 @@ describe("HTTP OAuth connect with real connectors and mock authorization server"
let issued = 0; let issued = 0;
let failExchange = false; let failExchange = false;
let bridgeConfigured = false; let bridgeConfigured = false;
let instagramConfigured = false;
const botToken = "12345:bot_token_secret"; const botToken = "12345:bot_token_secret";
beforeEach(() => { beforeEach(() => {
store.rows.clear(); store.audit.length = 0; store.authorized = true; store.rows.clear(); store.audit.length = 0; store.authorized = true;
redis.entries.clear(); activeTokens.clear(); grants.clear(); revokedTokens.length = 0; redis.entries.clear(); activeTokens.clear(); grants.clear(); revokedTokens.length = 0;
nextAccount = "account-1"; issued = 0; failExchange = false; bridgeConfigured = false; nextAccount = "account-1"; issued = 0; failExchange = false; bridgeConfigured = false; instagramConfigured = false;
warn.mockClear(); info.mockClear(); warn.mockClear(); info.mockClear();
}); });
@ -180,6 +183,9 @@ describe("HTTP OAuth connect with real connectors and mock authorization server"
})); }));
const botFetch: typeof fetch = (input, init) => fetch(String(input).replace("https://api.telegram.org", origin), init); const botFetch: typeof fetch = (input, init) => fetch(String(input).replace("https://api.telegram.org", origin), init);
registry.register(createTelegramConnector({ credentials, fetchImpl: botFetch, requestsPerSecond: 100_000 })); registry.register(createTelegramConnector({ credentials, fetchImpl: botFetch, requestsPerSecond: 100_000 }));
registry.register(createInstagramConnector({ credentials, fetchImpl: mockFetch, requestsPerSecond: 100_000,
env: instagramConfigured ? { INSTAGRAM_CLIENT_ID: "hub-app-id", INSTAGRAM_CLIENT_SECRET: "hub-app-secret" } : {},
}));
return registry; return registry;
}, },
}; };
@ -342,6 +348,25 @@ describe("HTTP OAuth connect with real connectors and mock authorization server"
bridgeConfigured = false; bridgeConfigured = false;
}); });
it("offers Instagram only with the Hub's Facebook app configured, and keeps the app secret out of the browser", async () => {
expect(connectionRegistry({} as never).has("instagram")).toBe(true);
const providersOf = async () => await (await api("/providers")).json() as Array<{ id: string; enabled: boolean; note?: string }>;
expect((await providersOf()).find((provider) => provider.id === "instagram")).toMatchObject({ enabled: false, note: expect.stringContaining("INSTAGRAM_CLIENT_ID") });
expect((await api("/authorize", "POST", { provider: "instagram" })).status).toBe(400);
instagramConfigured = true;
expect((await providersOf()).find((provider) => provider.id === "instagram")?.enabled).toBe(true);
const response = await api("/authorize", "POST", { provider: "instagram" });
expect(response.status).toBe(201);
const { redirectUrl } = await response.json() as { redirectUrl: string };
const dialog = new URL(redirectUrl);
expect(dialog.origin + dialog.pathname).toBe("https://www.facebook.com/v21.0/dialog/oauth");
expect(dialog.searchParams.get("client_id")).toBe("hub-app-id");
expect(dialog.searchParams.get("redirect_uri")).toBe(`${apiOrigin}/connections/callback`);
expect(dialog.searchParams.get("state")).toMatch(/.{32,}/);
expect(redirectUrl).not.toContain("hub-app-secret");
instagramConfigured = false;
});
it("sets, validates and clears per-connection publish governance", async () => { it("sets, validates and clears per-connection publish governance", async () => {
await finish(await begin()); await finish(await begin());
const id = (await listed())[0]?.id ?? ""; const id = (await listed())[0]?.id ?? "";

View file

@ -1,5 +1,7 @@
import { ConnectorRegistry, type CredentialAccessor } from "@tactical-media-broadcast-hub/connector-sdk"; import { ConnectorRegistry, type CredentialAccessor } from "@tactical-media-broadcast-hub/connector-sdk";
// eslint-disable-next-line no-restricted-syntax -- Connector composition boundary. // eslint-disable-next-line no-restricted-syntax -- Connector composition boundary.
import { createInstagramConnector } from "@tactical-media-broadcast-hub/connector-instagram";
// eslint-disable-next-line no-restricted-syntax -- Connector composition boundary.
import { createMastodonConnector } from "@tactical-media-broadcast-hub/connector-mastodon"; import { createMastodonConnector } from "@tactical-media-broadcast-hub/connector-mastodon";
// eslint-disable-next-line no-restricted-syntax -- Connector composition boundary. // eslint-disable-next-line no-restricted-syntax -- Connector composition boundary.
import { createPeerTubeConnector } from "../../../../packages/connector-peertube/src/index.js"; import { createPeerTubeConnector } from "../../../../packages/connector-peertube/src/index.js";
@ -13,5 +15,6 @@ export function connectionRegistry(credentials: CredentialAccessor, fetchImpl: t
registry.register(createMastodonConnector(options)); registry.register(createMastodonConnector(options));
registry.register(createPeerTubeConnector(options)); registry.register(createPeerTubeConnector(options));
registry.register(createTelegramConnector(options)); registry.register(createTelegramConnector(options));
registry.register(createInstagramConnector(options));
return registry; return registry;
} }

View file

@ -1,5 +1,7 @@
import { BadRequestException, ForbiddenException, Inject, Injectable, NotFoundException, Optional, ConflictException } from "@nestjs/common"; import { BadRequestException, ForbiddenException, Inject, Injectable, NotFoundException, Optional, ConflictException } from "@nestjs/common";
// eslint-disable-next-line no-restricted-syntax -- Connector composition boundary. // eslint-disable-next-line no-restricted-syntax -- Connector composition boundary.
import { createInstagramConnector } from "@tactical-media-broadcast-hub/connector-instagram";
// eslint-disable-next-line no-restricted-syntax -- Connector composition boundary.
import { createMastodonConnector } from "@tactical-media-broadcast-hub/connector-mastodon"; import { createMastodonConnector } from "@tactical-media-broadcast-hub/connector-mastodon";
import { isPublishingProvider, providerFetch } from "@tactical-media-broadcast-hub/connector-runtime"; import { isPublishingProvider, providerFetch } from "@tactical-media-broadcast-hub/connector-runtime";
import { ConnectorRegistry, type CredentialAccessor, type PlatformConnector } from "@tactical-media-broadcast-hub/connector-sdk"; import { ConnectorRegistry, type CredentialAccessor, type PlatformConnector } from "@tactical-media-broadcast-hub/connector-sdk";
@ -129,6 +131,7 @@ function connectorFor(provider: string): PlatformConnector {
}; };
registry.register(createMastodonConnector(options)); registry.register(createMastodonConnector(options));
registry.register(createTelegramConnector(options)); registry.register(createTelegramConnector(options));
registry.register(createInstagramConnector(options));
return registry; return registry;
})(); })();
return publishingConnectors.get(provider); return publishingConnectors.get(provider);

View file

@ -1,6 +1,6 @@
# Connecting platform accounts # Connecting platform accounts
The API resolves authorization behavior through connector methods and manifests. It currently registers Mastodon, PeerTube, and Telegram. Other providers are not offered as connectable until a connector implements the same authorization surface. 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 ## Configuration
@ -16,6 +16,7 @@ The API resolves authorization behavior through connector methods and manifests.
| `MASTODON_CLIENT_ID`, `MASTODON_CLIENT_SECRET` | Optional pre-registered Mastodon application credentials, used only for `MASTODON_INSTANCE_URL`. | | `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_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`. | | `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. | | `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. 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.
@ -30,6 +31,14 @@ Stock PeerTube documents local OAuth client discovery followed by a **password g
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. 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 ### 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. 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.

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { ConnectorConnection, NormalizedPost } from "@tactical-media-broadcast-hub/core"; import type { ConnectorConnection, NormalizedPost } from "@tactical-media-broadcast-hub/core";
import { AuthError, InMemoryCredentials } from "@tactical-media-broadcast-hub/connector-sdk"; import { InMemoryCredentials } from "@tactical-media-broadcast-hub/connector-sdk";
import { connectorContractTests } from "@tactical-media-broadcast-hub/connector-sdk/testing"; import { connectorContractTests } from "@tactical-media-broadcast-hub/connector-sdk/testing";
import { import {
AuthorizationRedirectRequired, AuthorizationRedirectRequired,
@ -19,7 +19,6 @@ interface StubCall {
init: RequestInit | undefined; init: RequestInit | undefined;
} }
const API = "https://graph.facebook.com/v21.0";
const DIALOG = "https://www.facebook.com/v21.0/dialog/oauth"; const DIALOG = "https://www.facebook.com/v21.0/dialog/oauth";
const REDIRECT = "https://app.example.com/oauth/instagram/callback"; const REDIRECT = "https://app.example.com/oauth/instagram/callback";
const IG_USER_ID = "17841400000000001"; const IG_USER_ID = "17841400000000001";
@ -169,11 +168,12 @@ interface Harness {
credentials: InMemoryCredentials; credentials: InMemoryCredentials;
} }
/** The Hub's Facebook app lives in the API environment, never in a connection. */
const APP_ENV = { INSTAGRAM_CLIENT_ID: "stub-client-id", INSTAGRAM_CLIENT_SECRET: "stub-client-secret" };
async function createHarness(seedToken = true): Promise<Harness> { async function createHarness(seedToken = true): Promise<Harness> {
const stub = { calls: [], nextId: 100 }; const stub = { calls: [], nextId: 100 };
const credentials = new InMemoryCredentials(); const credentials = new InMemoryCredentials();
await credentials.put(connection, "client_id", "stub-client-id");
await credentials.put(connection, "client_secret", "stub-client-secret");
await credentials.put(connection, "ig_user_id", IG_USER_ID); await credentials.put(connection, "ig_user_id", IG_USER_ID);
if (seedToken) await credentials.put(connection, "access_token", LONG_TOKEN); if (seedToken) await credentials.put(connection, "access_token", LONG_TOKEN);
const connector = createInstagramConnector({ const connector = createInstagramConnector({
@ -181,7 +181,7 @@ async function createHarness(seedToken = true): Promise<Harness> {
requestsPerSecond: 1000, requestsPerSecond: 1000,
sleep: async () => Promise.resolve(), sleep: async () => Promise.resolve(),
credentials, credentials,
env: {}, env: APP_ENV,
}); });
return { stub, connector, credentials }; return { stub, connector, credentials };
} }
@ -190,8 +190,6 @@ const leg1: InstagramAuthorizationRequest = {
redirectUri: REDIRECT, redirectUri: REDIRECT,
workspaceId: "ws-1", workspaceId: "ws-1",
state: "csrf-1", state: "csrf-1",
// App credentials arrive as connection fields on the connect form.
fields: { clientId: "stub-client-id", clientSecret: "stub-client-secret" },
}; };
const post = (text: string, mediaAssetIds: string[] = [], providerMetadata: Record<string, unknown> = {}): NormalizedPost => ({ const post = (text: string, mediaAssetIds: string[] = [], providerMetadata: Record<string, unknown> = {}): NormalizedPost => ({
@ -250,9 +248,9 @@ describe("instagram connector: authorize + identity", () => {
expect(dialog.searchParams.get("scope")).toBe("instagram_basic,instagram_content_publish,pages_show_list"); expect(dialog.searchParams.get("scope")).toBe("instagram_basic,instagram_content_publish,pages_show_list");
expect(dialog.searchParams.get("state")).toBe("csrf-1"); expect(dialog.searchParams.get("state")).toBe("csrf-1");
expect(redirect.connectionId).toBe("instagram:ws-1"); expect(redirect.connectionId).toBe("instagram:ws-1");
// App credentials persist for leg 2. // App credentials stay in the environment: nothing app-level reaches the vault.
expect(await credentials.getSecret({ ...connection, id: redirect.connectionId }, "client_id")).toBe("stub-client-id"); await expect(credentials.getSecret({ ...connection, id: redirect.connectionId }, "client_id")).rejects.toThrow();
expect(await credentials.getSecret({ ...connection, id: redirect.connectionId }, "client_secret")).toBe("stub-client-secret"); await expect(credentials.getSecret({ ...connection, id: redirect.connectionId }, "client_secret")).rejects.toThrow();
expect(stub.calls.filter((c) => pathOf(c) === "/oauth/access_token")).toHaveLength(0); expect(stub.calls.filter((c) => pathOf(c) === "/oauth/access_token")).toHaveLength(0);
}); });
@ -299,7 +297,7 @@ describe("instagram connector: authorize + identity", () => {
}); });
it("fails loudly when no Page carries an instagram_business_account (personal account)", async () => { it("fails loudly when no Page carries an instagram_business_account (personal account)", async () => {
const fetchImpl: typeof fetch = async (input, init) => { const fetchImpl: typeof fetch = async (input) => {
const href = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; const href = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
const url = new URL(href); const url = new URL(href);
url.pathname = url.pathname.replace(/^\/v21\.0/, ""); url.pathname = url.pathname.replace(/^\/v21\.0/, "");
@ -312,20 +310,56 @@ describe("instagram connector: authorize + identity", () => {
} }
return graphError("no route", 100, 404); return graphError("no route", 100, 404);
}; };
const credentials = new InMemoryCredentials();
await credentials.put(connection, "client_id", "stub-client-id");
await credentials.put(connection, "client_secret", "stub-client-secret");
const connector = createInstagramConnector({ const connector = createInstagramConnector({
fetchImpl, fetchImpl,
requestsPerSecond: 1000, requestsPerSecond: 1000,
sleep: async () => undefined, sleep: async () => undefined,
credentials, credentials: new InMemoryCredentials(),
env: {}, env: APP_ENV,
}); });
const request: InstagramAuthorizationRequest = { ...leg1, providerMetadata: { code: "good-code" } }; const request: InstagramAuthorizationRequest = { ...leg1, providerMetadata: { code: "good-code" } };
await expect(connector.authorize(request)).rejects.toThrow(/no Instagram professional account is linked/); await expect(connector.authorize(request)).rejects.toThrow(/no Instagram professional account is linked/);
}); });
it("refuses to pick one when several Instagram accounts were granted", async () => {
const fetchImpl: typeof fetch = async (input) => {
const href = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
const url = new URL(href);
url.pathname = url.pathname.replace(/^\/v21\.0/, "");
if (url.pathname === "/oauth/access_token") {
return json({ access_token: url.searchParams.get("code") ? SHORT_TOKEN : LONG_TOKEN, token_type: "bearer" });
}
if (url.pathname === "/me") return json({ id: FB_USER_ID });
if (url.pathname === "/me/accounts") {
return json({
data: [
{ id: "page-a", instagram_business_account: { id: "17841400000000001", username: "acampada_a" } },
{ id: "page-b", instagram_business_account: { id: "17841400000000002", username: "acampada_b" } },
],
});
}
return graphError("no route", 100, 404);
};
const connector = createInstagramConnector({
fetchImpl,
requestsPerSecond: 1000,
sleep: async () => undefined,
credentials: new InMemoryCredentials(),
env: APP_ENV,
});
const request: InstagramAuthorizationRequest = { ...leg1, providerMetadata: { code: "good-code" } };
await expect(connector.authorize(request)).rejects.toThrow(/2 Instagram professional accounts were granted/);
});
it("is not connectable until the Hub's Facebook app is configured", async () => {
const connector = createInstagramConnector({ credentials: new InMemoryCredentials(), env: {} });
expect(connector.manifest().oauth?.connectEnabled).toBe(false);
expect(connector.manifest().connectionFields.map((field) => field.name)).toEqual(["igUserId"]);
await expect(connector.authorize(leg1)).rejects.toThrow(/INSTAGRAM_CLIENT_ID/);
const configured = createInstagramConnector({ credentials: new InMemoryCredentials(), env: APP_ENV });
expect(configured.manifest().oauth?.connectEnabled).toBe(true);
});
it("getIdentity re-resolves the stored IG account through /me + /me/accounts", async () => { it("getIdentity re-resolves the stored IG account through /me + /me/accounts", async () => {
const { stub, connector } = await createHarness(); const { stub, connector } = await createHarness();
const identity = await connector.getIdentity(connection); const identity = await connector.getIdentity(connection);
@ -374,8 +408,6 @@ describe("instagram connector: publish", () => {
it("uploads assets through loadAsset and captions the container", async () => { it("uploads assets through loadAsset and captions the container", async () => {
const stub = { calls: [] as StubCall[], nextId: 100 }; const stub = { calls: [] as StubCall[], nextId: 100 };
const credentials = new InMemoryCredentials(); const credentials = new InMemoryCredentials();
await credentials.put(connection, "client_id", "stub-client-id");
await credentials.put(connection, "client_secret", "stub-client-secret");
await credentials.put(connection, "ig_user_id", IG_USER_ID); await credentials.put(connection, "ig_user_id", IG_USER_ID);
await credentials.put(connection, "access_token", LONG_TOKEN); await credentials.put(connection, "access_token", LONG_TOKEN);
const connector = createInstagramConnector({ const connector = createInstagramConnector({
@ -415,8 +447,6 @@ describe("instagram connector: publish", () => {
const stub = { calls: [] as StubCall[], nextId: 100 }; const stub = { calls: [] as StubCall[], nextId: 100 };
const credentials = new InMemoryCredentials(); const credentials = new InMemoryCredentials();
for (const [key, value] of [ for (const [key, value] of [
["client_id", "stub-client-id"],
["client_secret", "stub-client-secret"],
["ig_user_id", IG_USER_ID], ["ig_user_id", IG_USER_ID],
["access_token", LONG_TOKEN], ["access_token", LONG_TOKEN],
] as const) { ] as const) {
@ -458,8 +488,6 @@ describe("instagram connector: publish", () => {
const stub = { calls: [] as StubCall[], nextId: 100 }; const stub = { calls: [] as StubCall[], nextId: 100 };
const credentials = new InMemoryCredentials(); const credentials = new InMemoryCredentials();
for (const [key, value] of [ for (const [key, value] of [
["client_id", "stub-client-id"],
["client_secret", "stub-client-secret"],
["ig_user_id", IG_USER_ID], ["ig_user_id", IG_USER_ID],
["access_token", LONG_TOKEN], ["access_token", LONG_TOKEN],
] as const) { ] as const) {
@ -499,7 +527,6 @@ describe("instagram connector: publish", () => {
it("rejects non-JPEG assets", async () => { it("rejects non-JPEG assets", async () => {
const { connector } = await createHarness(); const { connector } = await createHarness();
(connector as unknown as { options?: unknown });
await expect( await expect(
connector.publish?.(connection, post("png", ["asset-png"], { connector.publish?.(connection, post("png", ["asset-png"], {
media: [], media: [],
@ -538,8 +565,6 @@ describe("instagram connector: deletePublication", () => {
const stub404Calls: StubCall[] = []; const stub404Calls: StubCall[] = [];
const credentials = new InMemoryCredentials(); const credentials = new InMemoryCredentials();
for (const [key, value] of [ for (const [key, value] of [
["client_id", "stub-client-id"],
["client_secret", "stub-client-secret"],
["ig_user_id", IG_USER_ID], ["ig_user_id", IG_USER_ID],
["access_token", LONG_TOKEN], ["access_token", LONG_TOKEN],
] as const) { ] as const) {

View file

@ -10,7 +10,9 @@
* immediately exchanges it (grant_type=fb_exchange_token) for a long-lived * immediately exchanges it (grant_type=fb_exchange_token) for a long-lived
* (~60 day) token, then resolves the Instagram professional account through * (~60 day) token, then resolves the Instagram professional account through
* GET /me + GET /me/accounts (instagram_business_account). Stored credential * GET /me + GET /me/accounts (instagram_business_account). Stored credential
* keys: access_token, ig_user_id, client_id, client_secret. * keys: access_token, ig_user_id. The Facebook app credentials identify the
* Hub, not a collective: they come only from INSTAGRAM_CLIENT_ID /
* INSTAGRAM_CLIENT_SECRET in the API environment and never enter the vault.
* *
* Honest capability notes (verified against the Graph API): * Honest capability notes (verified against the Graph API):
* - Instagram has no text-only posts. publishText is declared only because * - Instagram has no text-only posts. publishText is declared only because
@ -100,8 +102,6 @@ const CAPABILITIES: ConnectorCapabilities = {
const SECRET = { const SECRET = {
accessToken: "access_token", accessToken: "access_token",
igUserId: "ig_user_id", igUserId: "ig_user_id",
clientId: "client_id",
clientSecret: "client_secret",
} as const; } as const;
export interface InstagramConnectorOptions { export interface InstagramConnectorOptions {
@ -115,7 +115,7 @@ export interface InstagramConnectorOptions {
credentials?: CredentialAccessor; credentials?: CredentialAccessor;
/** Graph API version path segment. Default v21.0. */ /** Graph API version path segment. Default v21.0. */
apiVersion?: string; apiVersion?: string;
/** Facebook app credentials fallback when not supplied per connection. */ /** INSTAGRAM_CLIENT_ID / INSTAGRAM_CLIENT_SECRET: the Hub's Facebook app. */
env?: Record<string, string | undefined>; env?: Record<string, string | undefined>;
/** Resolve a media asset id to its metadata (needed by publish with media). */ /** Resolve a media asset id to its metadata (needed by publish with media). */
loadAsset?: (assetId: string) => Promise<MediaAsset>; loadAsset?: (assetId: string) => Promise<MediaAsset>;
@ -270,18 +270,6 @@ export function createInstagramConnector(options: InstagramConnectorOptions = {}
displayName: "Instagram", displayName: "Instagram",
capabilities: CAPABILITIES, capabilities: CAPABILITIES,
connectionFields: [ connectionFields: [
{
name: "clientId",
label: "Facebook app ID (or INSTAGRAM_CLIENT_ID env)",
required: false,
secret: false,
},
{
name: "clientSecret",
label: "Facebook app secret (or INSTAGRAM_CLIENT_SECRET env)",
required: false,
secret: true,
},
{ {
name: "igUserId", name: "igUserId",
label: "Instagram professional account id (optional — resolved at connect)", label: "Instagram professional account id (optional — resolved at connect)",
@ -296,14 +284,12 @@ export function createInstagramConnector(options: InstagramConnectorOptions = {}
authorizeUrlTemplate: `${dialogBase}/dialog/oauth`, authorizeUrlTemplate: `${dialogBase}/dialog/oauth`,
scopes: SCOPES, scopes: SCOPES,
pkce: false, pkce: false,
connectEnabled: Boolean(str(env.INSTAGRAM_CLIENT_ID) && str(env.INSTAGRAM_CLIENT_SECRET)),
connectNote: "Instagram linking needs the Hub's Facebook app: set INSTAGRAM_CLIENT_ID and INSTAGRAM_CLIENT_SECRET on the API.",
}, },
docsUrl: "https://developers.facebook.com/docs/instagram-platform/content-publishing", docsUrl: "https://developers.facebook.com/docs/instagram-platform/content-publishing",
}; };
function igUserIdOf(connection: ConnectorConnection): string {
return str(connection.fields.igUserId) ?? `vault:${connection.id}`;
}
/** Vault first; the connection field is the dev/test fallback. */ /** Vault first; the connection field is the dev/test fallback. */
async function tokenOf(connection: ConnectorConnection): Promise<string> { async function tokenOf(connection: ConnectorConnection): Promise<string> {
try { try {
@ -333,24 +319,12 @@ export function createInstagramConnector(options: InstagramConnectorOptions = {}
} }
} }
/** Facebook app credentials: vault first, then connection fields, then env. */ /** The Hub's Facebook app credentials — server environment only, never per connection. */
void secretOrUndefined; function clientCredentials(): { clientId: string; clientSecret: string } {
async function clientCredentials( const clientId = str(env.INSTAGRAM_CLIENT_ID);
connection: ConnectorConnection, const clientSecret = str(env.INSTAGRAM_CLIENT_SECRET);
): Promise<{ clientId: string; clientSecret: string }> {
const clientId =
(await secretOrUndefined(connection, SECRET.clientId)) ??
str(connection.fields.clientId) ??
str(env.INSTAGRAM_CLIENT_ID);
const clientSecret =
(await secretOrUndefined(connection, SECRET.clientSecret)) ??
str(connection.fields.clientSecret) ??
str(env.INSTAGRAM_CLIENT_SECRET);
if (clientId === undefined || clientSecret === undefined) { if (clientId === undefined || clientSecret === undefined) {
throw new ConnectorError( throw new ConnectorError(`${PROVIDER}: no Facebook app credentials — set INSTAGRAM_CLIENT_ID/INSTAGRAM_CLIENT_SECRET`, PROVIDER);
`${PROVIDER}: no Facebook app credentials — set fields.clientId/clientSecret or INSTAGRAM_CLIENT_ID/INSTAGRAM_CLIENT_SECRET`,
PROVIDER,
);
} }
return { clientId, clientSecret }; return { clientId, clientSecret };
} }
@ -408,16 +382,27 @@ export function createInstagramConnector(options: InstagramConnectorOptions = {}
/** /**
* GET /me validates the token (it is the Facebook user under Facebook * GET /me validates the token (it is the Facebook user under Facebook
* Login), then GET /me/accounts resolves the first Page carrying an * Login), then GET /me/accounts resolves the Page carrying an
* instagram_business_account — the IG professional account to publish as. * instagram_business_account — the IG professional account to publish as.
* Exactly one must be granted: picking one of several would publish as an
* account nobody chose, so the person re-links selecting a single one in
* Meta's dialog.
*/ */
async function fetchIdentity(connection: ConnectorConnection): Promise<NormalizedIdentity> { async function fetchIdentity(connection: ConnectorConnection): Promise<NormalizedIdentity> {
const me = await graph<FacebookUser>(connection, "GET", "/me", { fields: "id,name" }); const me = await graph<FacebookUser>(connection, "GET", "/me", { fields: "id,name" });
const { data } = await graph<GraphList<FacebookPage>>(connection, "GET", "/me/accounts", { const { data } = await graph<GraphList<FacebookPage>>(connection, "GET", "/me/accounts", {
fields: PAGE_FIELDS, fields: PAGE_FIELDS,
}); });
const linked = data.find((page) => page.instagram_business_account !== undefined) const granted = data
?.instagram_business_account; .map((page) => page.instagram_business_account)
.filter((account): account is NonNullable<FacebookPage["instagram_business_account"]> => account !== undefined);
if (granted.length > 1) {
throw new ConnectorError(
`${PROVIDER}: ${granted.length} Instagram professional accounts were granted — select exactly one in the Facebook dialog`,
PROVIDER,
);
}
const linked = granted[0];
if (linked === undefined) { if (linked === undefined) {
throw new ConnectorError( throw new ConnectorError(
`${PROVIDER}: no Instagram professional account is linked to any Page of Facebook user ${me.id} — convert the account to business/creator and link a Page`, `${PROVIDER}: no Instagram professional account is linked to any Page of Facebook user ${me.id} — convert the account to business/creator and link a Page`,
@ -516,7 +501,7 @@ export function createInstagramConnector(options: InstagramConnectorOptions = {}
provider: PROVIDER, provider: PROVIDER,
channelId: error.connectionId, channelId: error.connectionId,
credentialId: { kind: "credential-ref", id: error.connectionId }, credentialId: { kind: "credential-ref", id: error.connectionId },
fields: input.fields?.clientId !== undefined ? { clientId: input.fields.clientId } : {}, fields: {},
createdAt: new Date(), createdAt: new Date(),
}; };
return { authorizationUrl: error.authorizationUrl, connection }; return { authorizationUrl: error.authorizationUrl, connection };
@ -531,7 +516,7 @@ export function createInstagramConnector(options: InstagramConnectorOptions = {}
}, },
/** /**
* Leg 1 (no code): validate app credentials, store them in the vault and * Leg 1 (no code): read the Hub's app credentials from the environment and
* throw AuthorizationRedirectRequired with the Facebook dialog URL. * throw AuthorizationRedirectRequired with the Facebook dialog URL.
* Leg 2 (providerMetadata.code): code → short-lived token → long-lived * Leg 2 (providerMetadata.code): code → short-lived token → long-lived
* token (fb_exchange_token) → resolve the IG professional account via * token (fb_exchange_token) → resolve the IG professional account via
@ -542,24 +527,17 @@ export function createInstagramConnector(options: InstagramConnectorOptions = {}
const code = str(meta.code); const code = str(meta.code);
const connectionId = str(input.connectionId) ?? str(meta.connectionId) ?? `${PROVIDER}:${input.workspaceId}`; const connectionId = str(input.connectionId) ?? str(meta.connectionId) ?? `${PROVIDER}:${input.workspaceId}`;
const credentialId: CredentialRef = { kind: "credential-ref", id: connectionId }; const credentialId: CredentialRef = { kind: "credential-ref", id: connectionId };
const provided: Record<string, string> = {};
const fieldClientId = str(input.fields?.clientId);
const fieldClientSecret = str(input.fields?.clientSecret);
if (fieldClientId !== undefined) provided.clientId = fieldClientId;
if (fieldClientSecret !== undefined) provided.clientSecret = fieldClientSecret;
const connection: ConnectorConnection = { const connection: ConnectorConnection = {
id: connectionId, id: connectionId,
workspaceId: input.workspaceId, workspaceId: input.workspaceId,
provider: PROVIDER, provider: PROVIDER,
channelId: str(meta.channelId) ?? connectionId, channelId: str(meta.channelId) ?? connectionId,
credentialId, credentialId,
fields: { ...provided }, fields: {},
createdAt: new Date(), createdAt: new Date(),
}; };
const client = await clientCredentials({ ...connection, fields: provided }); const client = clientCredentials();
await credentials.put(connection, SECRET.clientId, client.clientId);
await credentials.put(connection, SECRET.clientSecret, client.clientSecret);
if (code === undefined) { if (code === undefined) {
const url = new URL(`${dialogBase}/dialog/oauth`); const url = new URL(`${dialogBase}/dialog/oauth`);

View file

@ -26,6 +26,8 @@ export const MASTODON_DEDUPE_WINDOW_MS = 30 * 60 * 1000;
export const PUBLISHING_POLICIES: Readonly<Record<string, PublishingPolicy>> = { export const PUBLISHING_POLICIES: Readonly<Record<string, PublishingPolicy>> = {
mastodon: { idempotency: "token", dedupeWindowMs: MASTODON_DEDUPE_WINDOW_MS }, mastodon: { idempotency: "token", dedupeWindowMs: MASTODON_DEDUPE_WINDOW_MS },
telegram: { idempotency: "none" }, telegram: { idempotency: "none" },
// Graph media_publish takes no idempotency key: an ambiguous send is never replayed.
instagram: { idempotency: "none" },
}; };
/** Unknown providers get the safe default: never replay an ambiguous send. */ /** Unknown providers get the safe default: never replay an ambiguous send. */

3
pnpm-lock.yaml generated
View file

@ -50,6 +50,9 @@ importers:
'@opentelemetry/sdk-node': '@opentelemetry/sdk-node':
specifier: ^0.222.0 specifier: ^0.222.0
version: 0.222.0(@opentelemetry/api@1.9.1) version: 0.222.0(@opentelemetry/api@1.9.1)
'@tactical-media-broadcast-hub/connector-instagram':
specifier: workspace:*
version: link:../../packages/connector-instagram
'@tactical-media-broadcast-hub/connector-mastodon': '@tactical-media-broadcast-hub/connector-mastodon':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/connector-mastodon version: link:../../packages/connector-mastodon