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 • committed by glass beadian
parent 4f0b69f5aa
commit 57762f543c
9 changed files with 127 additions and 78 deletions

View file

@ -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) {

View file

@ -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`);

View file

@ -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. */