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:
parent
4f0b69f5aa
commit
57762f543c
9 changed files with 127 additions and 78 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 ?? "";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<Harness> {
|
||||
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<Harness> {
|
|||
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<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("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) {
|
||||
|
|
|
|||
|
|
@ -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<string, string | undefined>;
|
||||
/** Resolve a media asset id to its metadata (needed by publish with media). */
|
||||
loadAsset?: (assetId: string) => Promise<MediaAsset>;
|
||||
|
|
@ -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<string> {
|
||||
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<NormalizedIdentity> {
|
||||
const me = await graph<FacebookUser>(connection, "GET", "/me", { fields: "id,name" });
|
||||
const { data } = await graph<GraphList<FacebookPage>>(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<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) {
|
||||
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<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 = {
|
||||
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`);
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ export const MASTODON_DEDUPE_WINDOW_MS = 30 * 60 * 1000;
|
|||
export const PUBLISHING_POLICIES: Readonly<Record<string, PublishingPolicy>> = {
|
||||
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. */
|
||||
|
|
|
|||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue