From 80aa198843acc4ce1b352a1303cc998c65d0129e Mon Sep 17 00:00:00 2001 From: hacklab Date: Thu, 15 Oct 2026 14:15:03 +0200 Subject: [PATCH] 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. --- apps/api/package.json | 1 + .../connections.integration.test.ts | 27 ++++++- .../api/src/connections/connector-registry.ts | 3 + apps/api/src/publishing/publishing.service.ts | 3 + docs/oauth-connect.md | 11 ++- .../connector-instagram/src/contract.test.ts | 75 +++++++++++------ packages/connector-instagram/src/index.ts | 80 +++++++------------ packages/connector-runtime/src/policy.ts | 2 + pnpm-lock.yaml | 3 + 9 files changed, 127 insertions(+), 78 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index 2fe9eef..b6765eb 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -13,6 +13,7 @@ "dependencies": { "@tactical-media-broadcast-hub/connector-sdk": "workspace:*", "@tactical-media-broadcast-hub/connector-telegram": "workspace:*", + "@tactical-media-broadcast-hub/connector-instagram": "workspace:*", "@tactical-media-broadcast-hub/core": "workspace:*", "@aws-sdk/client-s3": "^3.1146.0", "@aws-sdk/s3-request-presigner": "^3.1146.0", diff --git a/apps/api/src/connections/connections.integration.test.ts b/apps/api/src/connections/connections.integration.test.ts index f3b999d..8d615d4 100644 --- a/apps/api/src/connections/connections.integration.test.ts +++ b/apps/api/src/connections/connections.integration.test.ts @@ -9,6 +9,8 @@ import { ConnectorRegistry } from "@tactical-media-broadcast-hub/connector-sdk"; import { createMastodonConnector } from "../../../../packages/connector-mastodon/src/index.js"; import { createTelegramConnector } from "@tactical-media-broadcast-hub/connector-telegram"; 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 type { SessionPayload } from "../auth/session.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 failExchange = false; let bridgeConfigured = false; + let instagramConfigured = false; const botToken = "12345:bot_token_secret"; beforeEach(() => { store.rows.clear(); store.audit.length = 0; store.authorized = true; 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(); }); @@ -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); 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; }, }; @@ -342,6 +348,25 @@ describe("HTTP OAuth connect with real connectors and mock authorization server" 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 () => { await finish(await begin()); const id = (await listed())[0]?.id ?? ""; diff --git a/apps/api/src/connections/connector-registry.ts b/apps/api/src/connections/connector-registry.ts index 8454727..e3e5b18 100644 --- a/apps/api/src/connections/connector-registry.ts +++ b/apps/api/src/connections/connector-registry.ts @@ -1,5 +1,7 @@ import { ConnectorRegistry, type CredentialAccessor } from "@tactical-media-broadcast-hub/connector-sdk"; // 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"; // eslint-disable-next-line no-restricted-syntax -- Connector composition boundary. 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(createPeerTubeConnector(options)); registry.register(createTelegramConnector(options)); + registry.register(createInstagramConnector(options)); return registry; } diff --git a/apps/api/src/publishing/publishing.service.ts b/apps/api/src/publishing/publishing.service.ts index f32d8c1..278edac 100644 --- a/apps/api/src/publishing/publishing.service.ts +++ b/apps/api/src/publishing/publishing.service.ts @@ -1,5 +1,7 @@ import { BadRequestException, ForbiddenException, Inject, Injectable, NotFoundException, Optional, ConflictException } from "@nestjs/common"; // 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 { isPublishingProvider, providerFetch } from "@tactical-media-broadcast-hub/connector-runtime"; 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(createTelegramConnector(options)); + registry.register(createInstagramConnector(options)); return registry; })(); return publishingConnectors.get(provider); diff --git a/docs/oauth-connect.md b/docs/oauth-connect.md index c84a1ac..8f6b9cf 100644 --- a/docs/oauth-connect.md +++ b/docs/oauth-connect.md @@ -1,6 +1,6 @@ # 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 @@ -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`. | | `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. @@ -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. +### 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. diff --git a/packages/connector-instagram/src/contract.test.ts b/packages/connector-instagram/src/contract.test.ts index 7f656b0..4892fc6 100644 --- a/packages/connector-instagram/src/contract.test.ts +++ b/packages/connector-instagram/src/contract.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; 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 { AuthorizationRedirectRequired, @@ -19,7 +19,6 @@ interface StubCall { init: RequestInit | undefined; } -const API = "https://graph.facebook.com/v21.0"; const DIALOG = "https://www.facebook.com/v21.0/dialog/oauth"; const REDIRECT = "https://app.example.com/oauth/instagram/callback"; const IG_USER_ID = "17841400000000001"; @@ -169,11 +168,12 @@ interface Harness { 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 { const stub = { calls: [], nextId: 100 }; 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); if (seedToken) await credentials.put(connection, "access_token", LONG_TOKEN); const connector = createInstagramConnector({ @@ -181,7 +181,7 @@ async function createHarness(seedToken = true): Promise { requestsPerSecond: 1000, sleep: async () => Promise.resolve(), credentials, - env: {}, + env: APP_ENV, }); return { stub, connector, credentials }; } @@ -190,8 +190,6 @@ const leg1: InstagramAuthorizationRequest = { redirectUri: REDIRECT, workspaceId: "ws-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 = {}): 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("state")).toBe("csrf-1"); expect(redirect.connectionId).toBe("instagram:ws-1"); - // App credentials persist for leg 2. - expect(await credentials.getSecret({ ...connection, id: redirect.connectionId }, "client_id")).toBe("stub-client-id"); - expect(await credentials.getSecret({ ...connection, id: redirect.connectionId }, "client_secret")).toBe("stub-client-secret"); + // App credentials stay in the environment: nothing app-level reaches the vault. + await expect(credentials.getSecret({ ...connection, id: redirect.connectionId }, "client_id")).rejects.toThrow(); + await expect(credentials.getSecret({ ...connection, id: redirect.connectionId }, "client_secret")).rejects.toThrow(); 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 () => { - 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 url = new URL(href); url.pathname = url.pathname.replace(/^\/v21\.0/, ""); @@ -312,20 +310,56 @@ describe("instagram connector: authorize + identity", () => { } 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({ fetchImpl, requestsPerSecond: 1000, sleep: async () => undefined, - credentials, - env: {}, + credentials: new InMemoryCredentials(), + env: APP_ENV, }); const request: InstagramAuthorizationRequest = { ...leg1, providerMetadata: { code: "good-code" } }; 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 () => { const { stub, connector } = await createHarness(); const identity = await connector.getIdentity(connection); @@ -374,8 +408,6 @@ describe("instagram connector: publish", () => { it("uploads assets through loadAsset and captions the container", async () => { const stub = { calls: [] as StubCall[], nextId: 100 }; 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, "access_token", LONG_TOKEN); const connector = createInstagramConnector({ @@ -415,8 +447,6 @@ describe("instagram connector: publish", () => { const stub = { calls: [] as StubCall[], nextId: 100 }; const credentials = new InMemoryCredentials(); for (const [key, value] of [ - ["client_id", "stub-client-id"], - ["client_secret", "stub-client-secret"], ["ig_user_id", IG_USER_ID], ["access_token", LONG_TOKEN], ] as const) { @@ -458,8 +488,6 @@ describe("instagram connector: publish", () => { const stub = { calls: [] as StubCall[], nextId: 100 }; const credentials = new InMemoryCredentials(); for (const [key, value] of [ - ["client_id", "stub-client-id"], - ["client_secret", "stub-client-secret"], ["ig_user_id", IG_USER_ID], ["access_token", LONG_TOKEN], ] as const) { @@ -499,7 +527,6 @@ describe("instagram connector: publish", () => { it("rejects non-JPEG assets", async () => { const { connector } = await createHarness(); - (connector as unknown as { options?: unknown }); await expect( connector.publish?.(connection, post("png", ["asset-png"], { media: [], @@ -538,8 +565,6 @@ describe("instagram connector: deletePublication", () => { const stub404Calls: StubCall[] = []; const credentials = new InMemoryCredentials(); for (const [key, value] of [ - ["client_id", "stub-client-id"], - ["client_secret", "stub-client-secret"], ["ig_user_id", IG_USER_ID], ["access_token", LONG_TOKEN], ] as const) { diff --git a/packages/connector-instagram/src/index.ts b/packages/connector-instagram/src/index.ts index c4144b9..db3ff70 100644 --- a/packages/connector-instagram/src/index.ts +++ b/packages/connector-instagram/src/index.ts @@ -10,7 +10,9 @@ * immediately exchanges it (grant_type=fb_exchange_token) for a long-lived * (~60 day) token, then resolves the Instagram professional account through * 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): * - Instagram has no text-only posts. publishText is declared only because @@ -100,8 +102,6 @@ const CAPABILITIES: ConnectorCapabilities = { const SECRET = { accessToken: "access_token", igUserId: "ig_user_id", - clientId: "client_id", - clientSecret: "client_secret", } as const; export interface InstagramConnectorOptions { @@ -115,7 +115,7 @@ export interface InstagramConnectorOptions { credentials?: CredentialAccessor; /** Graph API version path segment. Default v21.0. */ apiVersion?: string; - /** Facebook app credentials fallback when not supplied per connection. */ + /** INSTAGRAM_CLIENT_ID / INSTAGRAM_CLIENT_SECRET: the Hub's Facebook app. */ env?: Record; /** Resolve a media asset id to its metadata (needed by publish with media). */ loadAsset?: (assetId: string) => Promise; @@ -270,18 +270,6 @@ export function createInstagramConnector(options: InstagramConnectorOptions = {} displayName: "Instagram", capabilities: CAPABILITIES, 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", label: "Instagram professional account id (optional — resolved at connect)", @@ -296,14 +284,12 @@ export function createInstagramConnector(options: InstagramConnectorOptions = {} authorizeUrlTemplate: `${dialogBase}/dialog/oauth`, scopes: SCOPES, 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", }; - function igUserIdOf(connection: ConnectorConnection): string { - return str(connection.fields.igUserId) ?? `vault:${connection.id}`; - } - /** Vault first; the connection field is the dev/test fallback. */ async function tokenOf(connection: ConnectorConnection): Promise { try { @@ -333,24 +319,12 @@ export function createInstagramConnector(options: InstagramConnectorOptions = {} } } - /** Facebook app credentials: vault first, then connection fields, then env. */ - void secretOrUndefined; - async function clientCredentials( - connection: ConnectorConnection, - ): 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); + /** The Hub's Facebook app credentials — server environment only, never per connection. */ + function clientCredentials(): { clientId: string; clientSecret: string } { + const clientId = str(env.INSTAGRAM_CLIENT_ID); + const clientSecret = str(env.INSTAGRAM_CLIENT_SECRET); if (clientId === undefined || clientSecret === undefined) { - throw new ConnectorError( - `${PROVIDER}: no Facebook app credentials — set fields.clientId/clientSecret or INSTAGRAM_CLIENT_ID/INSTAGRAM_CLIENT_SECRET`, - PROVIDER, - ); + throw new ConnectorError(`${PROVIDER}: no Facebook app credentials — set INSTAGRAM_CLIENT_ID/INSTAGRAM_CLIENT_SECRET`, PROVIDER); } return { clientId, clientSecret }; } @@ -408,16 +382,27 @@ export function createInstagramConnector(options: InstagramConnectorOptions = {} /** * 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. + * 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 { const me = await graph(connection, "GET", "/me", { fields: "id,name" }); const { data } = await graph>(connection, "GET", "/me/accounts", { fields: PAGE_FIELDS, }); - const linked = data.find((page) => page.instagram_business_account !== undefined) - ?.instagram_business_account; + const granted = data + .map((page) => page.instagram_business_account) + .filter((account): account is NonNullable => 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) { 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`, @@ -516,7 +501,7 @@ export function createInstagramConnector(options: InstagramConnectorOptions = {} provider: PROVIDER, channelId: error.connectionId, credentialId: { kind: "credential-ref", id: error.connectionId }, - fields: input.fields?.clientId !== undefined ? { clientId: input.fields.clientId } : {}, + fields: {}, createdAt: new Date(), }; 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. * Leg 2 (providerMetadata.code): code → short-lived token → long-lived * 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 connectionId = str(input.connectionId) ?? str(meta.connectionId) ?? `${PROVIDER}:${input.workspaceId}`; const credentialId: CredentialRef = { kind: "credential-ref", id: connectionId }; - const provided: Record = {}; - 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 = { id: connectionId, workspaceId: input.workspaceId, provider: PROVIDER, channelId: str(meta.channelId) ?? connectionId, credentialId, - fields: { ...provided }, + fields: {}, createdAt: new Date(), }; - const client = await clientCredentials({ ...connection, fields: provided }); - await credentials.put(connection, SECRET.clientId, client.clientId); - await credentials.put(connection, SECRET.clientSecret, client.clientSecret); + const client = clientCredentials(); if (code === undefined) { const url = new URL(`${dialogBase}/dialog/oauth`); diff --git a/packages/connector-runtime/src/policy.ts b/packages/connector-runtime/src/policy.ts index 0dc23b1..e5d9c4a 100644 --- a/packages/connector-runtime/src/policy.ts +++ b/packages/connector-runtime/src/policy.ts @@ -26,6 +26,8 @@ export const MASTODON_DEDUPE_WINDOW_MS = 30 * 60 * 1000; export const PUBLISHING_POLICIES: Readonly> = { mastodon: { idempotency: "token", dedupeWindowMs: MASTODON_DEDUPE_WINDOW_MS }, 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. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d698cfe..b9e0838 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: '@opentelemetry/sdk-node': specifier: ^0.222.0 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': specifier: workspace:* version: link:../../packages/connector-mastodon