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. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
a6843e904b
commit
c96704fb6b
9 changed files with 127 additions and 78 deletions
|
|
@ -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. */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue