Tactical Media Broadcast Hub — initial release

Self-hosted multi-platform broadcast newsroom for activist collectives:
publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style
column wall with hostile-content-safe rendering, live control room with Auto
Director, bridged chat governance with polls and quorum, distributed sandboxed
transcode workers, encrypted content-addressed archive, monthly analytics.

MIT licensed. See README.md and docs/.
This commit is contained in:
glass beadian 2026-10-10 11:56:42 +00:00
commit aa5e262f3a
880 changed files with 111042 additions and 0 deletions

View file

@ -0,0 +1,22 @@
{
"name": "@tactical-media-broadcast-hub/connector-instagram",
"private": true,
"version": "0.1.0",
"type": "module",
"main": "src/index.ts",
"scripts": {
"build": "tsc -p tsconfig.json",
"lint": "eslint src",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run"
},
"dependencies": {
"@tactical-media-broadcast-hub/connector-sdk": "workspace:*",
"@tactical-media-broadcast-hub/core": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.20.5",
"typescript": "^5.5.4",
"vitest": "^2.0.5"
}
}

View file

@ -0,0 +1,572 @@
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 { connectorContractTests } from "@tactical-media-broadcast-hub/connector-sdk/testing";
import {
AuthorizationRedirectRequired,
assertCaption,
createInstagramConnector,
type InstagramAuthorizationRequest,
} from "./index.js";
/**
* Stubbed Graph API: canned JSON per endpoint, real request/response shapes.
* Every call is recorded so tests can assert on what was sent.
*/
interface StubCall {
method: string;
url: URL;
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";
const IG_USERNAME = "anarchoposter";
const PAGE_ID = "10000000000001";
const FB_USER_ID = "900000000000001";
const SHORT_TOKEN = "EAAW-short-lived-user-token";
const LONG_TOKEN = "EAAW-long-lived-user-token";
const REFRESHED_TOKEN = "EAAW-refreshed-long-lived-token";
const connection: ConnectorConnection = {
id: "conn-instagram",
workspaceId: "ws-1",
provider: "instagram",
channelId: "conn-instagram",
credentialId: { kind: "credential-ref", id: "cred-instagram" },
fields: { igUserId: IG_USER_ID },
createdAt: new Date(),
};
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
function graphError(message: string, code: number, status = 400): Response {
return json({ error: { message, code } }, status);
}
function formBody(init: RequestInit | undefined): URLSearchParams {
return new URLSearchParams(String(init?.body ?? ""));
}
function queryOf(call: StubCall | undefined): URLSearchParams {
return call?.url.searchParams ?? new URLSearchParams();
}
/** Request path without the Graph version segment (/v21.0/...). */
function pathOf(call: StubCall): string {
return call.url.pathname.replace(/^\/v21\.0/, "");
}
function headerOf(init: RequestInit | undefined, name: string): string | undefined {
return new Headers(init?.headers).get(name) ?? undefined;
}
function stubGraph(stub: { calls: StubCall[]; nextId: number }): typeof fetch {
return async (input: Parameters<typeof fetch>[0], init?: RequestInit): Promise<Response> => {
const href =
typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
const url = new URL(href);
const method = (init?.method ?? "GET").toUpperCase();
stub.calls.push({ method, url, init });
// Route matching ignores the version segment (/v21.0/...).
const route = `${method} ${url.pathname.replace(/^\/v21\.0/, "")}`;
// Token endpoint: both the code exchange and the fb_exchange_token upgrade.
if (route === "GET /oauth/access_token") {
if (url.searchParams.get("code") === "good-code") {
return json({ access_token: SHORT_TOKEN, token_type: "bearer", expires_in: 5183 });
}
if (url.searchParams.get("grant_type") === "fb_exchange_token") {
if (url.searchParams.get("fb_exchange_token") !== SHORT_TOKEN) {
return graphError("Invalid OAuth access token signature.", 190, 400);
}
return json({ access_token: LONG_TOKEN, token_type: "bearer", expires_in: 5_184_000 });
}
if (url.searchParams.get("grant_type") === "ig_refresh_token") {
if (url.searchParams.get("access_token") !== LONG_TOKEN) {
return graphError("The access token is invalid", 190, 400);
}
return json({ access_token: REFRESHED_TOKEN, token_type: "bearer", expires_in: 5_184_000 });
}
if (url.searchParams.get("code") === "bad-code") {
return graphError("Validation of the provided code failed.", 100, 400);
}
return graphError("Missing redirect_uri or code", 191, 400);
}
if (route === "GET /refresh_access_token") {
if (url.searchParams.get("grant_type") !== "ig_refresh_token") {
return graphError("Only ig_refresh_token is supported here", 100, 400);
}
if (url.searchParams.get("access_token") !== LONG_TOKEN) {
return graphError("The access token is invalid", 190, 400);
}
return json({ access_token: REFRESHED_TOKEN, token_type: "bearer", expires_in: 5_184_000 });
}
// Everything below is an authenticated Graph call.
if (headerOf(init, "authorization") !== `Bearer ${LONG_TOKEN}`) {
return graphError("Invalid OAuth access token.", 190, 401);
}
if (route === "GET /me") return json({ id: FB_USER_ID, name: "AnarchoPoster HQ" });
if (route === "GET /me/accounts") {
return json({
data: [
{
id: PAGE_ID,
name: "AnarchoPoster Page",
instagram_business_account: { id: IG_USER_ID, username: IG_USERNAME, name: "AnarchoPoster" },
},
],
});
}
if (route === `POST /${IG_USER_ID}/media`) {
const form = formBody(init);
if (form.get("image_url") !== null && !form.get("image_url")?.startsWith("https://")) {
return graphError("image_url must be an absolute https URL", 100);
}
const id = `container-${(stub.nextId += 1)}`;
return json({ id });
}
if (route === `POST /${IG_USER_ID}/media_publish`) {
const creationId = formBody(init).get("creation_id") ?? "";
if (creationId.startsWith("container-")) return json({ id: `media-${(stub.nextId += 1)}` });
return graphError("The container was not found", 100);
}
const media = /^GET \/media-(\d+)$/.exec(route);
if (media?.[1] !== undefined) {
return json({
id: `media-${media[1]}`,
permalink: `https://www.instagram.com/p/media-${media[1]}/`,
timestamp: "2026-10-06T12:00:00+0000",
media_type: "IMAGE",
});
}
if (route === `DELETE /${IG_USER_ID}`) return json({ success: true });
const deleted = /^DELETE \/media-(\d+)$/.exec(route);
if (deleted?.[1] !== undefined) return json({ success: true });
return graphError(`stub: no route for ${route}`, 100, 404);
};
}
interface Harness {
stub: { calls: StubCall[]; nextId: number };
connector: ReturnType<typeof createInstagramConnector>;
credentials: InMemoryCredentials;
}
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({
fetchImpl: stubGraph(stub),
requestsPerSecond: 1000,
sleep: async () => Promise.resolve(),
credentials,
env: {},
});
return { stub, connector, credentials };
}
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 => ({
text,
mediaAssetIds,
visibility: "public",
providerMetadata,
});
const asset = (assetId: string) => ({
id: assetId,
workspaceId: "ws-1",
storageKey: `https://cdn.example.com/${assetId}.jpg`,
mimeType: "image/jpeg",
sizeBytes: 12_345,
createdAt: new Date("2026-10-01T00:00:00.000Z"),
});
connectorContractTests((await createHarness()).connector, { connection });
describe("instagram connector: manifest", () => {
it("declares the scoped capability set", async () => {
const { connector } = await createHarness();
const caps = connector.manifest().capabilities;
// Declared only because the SDK couples `publish` to it; text-only posts throw.
expect(caps.publishText).toBe(true);
expect(caps.publishImages).toBe(true);
expect(caps.publishCarousel).toBe(true);
expect(caps.deletePublication).toBe(true);
expect(caps.nativeScheduling).toBe(false);
expect(caps.analytics).toBe(false);
});
it("oauth points at the Facebook Login dialog with the IG scopes", async () => {
const { connector } = await createHarness();
const oauth = connector.manifest().oauth;
expect(oauth?.authorizeUrlTemplate).toBe(DIALOG);
expect(oauth?.scopes).toEqual(["instagram_basic", "instagram_content_publish", "pages_show_list"]);
});
});
describe("instagram connector: authorize + identity", () => {
it("leg 1 redirects to the Facebook dialog with the three scopes", async () => {
const { stub, connector, credentials } = await createHarness(false);
const error = await connector.authorize(leg1).then(
() => undefined,
(e: unknown) => e,
);
expect(error).toBeInstanceOf(AuthorizationRedirectRequired);
const redirect = error as AuthorizationRedirectRequired;
expect(redirect.authorizationUrl.startsWith(DIALOG)).toBe(true);
const dialog = new URL(redirect.authorizationUrl);
expect(dialog.searchParams.get("client_id")).toBe("stub-client-id");
expect(dialog.searchParams.get("redirect_uri")).toBe(REDIRECT);
expect(dialog.searchParams.get("response_type")).toBe("code");
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");
expect(stub.calls.filter((c) => pathOf(c) === "/oauth/access_token")).toHaveLength(0);
});
it("leg 2 exchanges the code, upgrades to a long-lived token and resolves the IG business account", async () => {
const { stub, connector, credentials } = await createHarness(false);
await expect(connector.authorize(leg1)).rejects.toBeInstanceOf(AuthorizationRedirectRequired);
const leg2: InstagramAuthorizationRequest = { workspaceId: leg1.workspaceId, redirectUri: leg1.redirectUri, state: leg1.state, providerMetadata: { code: "good-code" } };
const result = await connector.authorize(leg2);
const exchanges = stub.calls.filter((c) => pathOf(c) === "/oauth/access_token");
expect(exchanges).toHaveLength(2);
const first = queryOf(exchanges[0]);
expect(first.get("code")).toBe("good-code");
expect(first.get("client_id")).toBe("stub-client-id");
expect(first.get("redirect_uri")).toBe(REDIRECT);
const upgrade = queryOf(exchanges[1]);
expect(upgrade.get("grant_type")).toBe("fb_exchange_token");
expect(upgrade.get("fb_exchange_token")).toBe(SHORT_TOKEN);
const accounts = stub.calls.find((c) => pathOf(c) === "/me/accounts");
expect(accounts).toBeDefined();
expect(queryOf(accounts).get("fields")).toContain("instagram_business_account");
expect(result.identity).toEqual({
provider: "instagram",
remoteId: IG_USER_ID,
handle: `@${IG_USERNAME}`,
displayName: "AnarchoPoster",
});
expect(result.connection.fields.igUserId).toBe(IG_USER_ID);
expect(await credentials.getSecret(result.connection, "access_token")).toBe(LONG_TOKEN);
expect(await credentials.getSecret(result.connection, "ig_user_id")).toBe(IG_USER_ID);
expect(await connector.getIdentity(result.connection)).toEqual(result.identity);
});
it("rejects a bad code with the Graph message", async () => {
const { connector } = await createHarness(false);
const request: InstagramAuthorizationRequest = {
...leg1,
providerMetadata: { code: "bad-code" },
};
await expect(connector.authorize(request)).rejects.toThrow(/Validation of the provided code failed/);
});
it("fails loudly when no Page carries an instagram_business_account (personal account)", async () => {
const fetchImpl: typeof fetch = async (input, init) => {
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_ID, name: "Page without IG" }] });
}
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: {},
});
const request: InstagramAuthorizationRequest = { ...leg1, providerMetadata: { code: "good-code" } };
await expect(connector.authorize(request)).rejects.toThrow(/no Instagram professional account is linked/);
});
it("getIdentity re-resolves the stored IG account through /me + /me/accounts", async () => {
const { stub, connector } = await createHarness();
const identity = await connector.getIdentity(connection);
expect(identity).toEqual({
provider: "instagram",
remoteId: IG_USER_ID,
handle: `@${IG_USERNAME}`,
displayName: "AnarchoPoster",
});
const calls = stub.calls.filter((c) => pathOf(c) === "/me/accounts");
expect(calls).toHaveLength(1);
expect(headerOf(calls[0]?.init, "authorization")).toBe(`Bearer ${LONG_TOKEN}`);
});
});
describe("instagram connector: refreshCredentials", () => {
it("refreshes via GET /refresh_access_token with grant_type=ig_refresh_token", async () => {
const { stub, connector, credentials } = await createHarness();
const credentialId = await connector.refreshCredentials?.(connection);
expect(credentialId).toEqual(connection.credentialId);
const call = stub.calls.find((c) => pathOf(c) === "/refresh_access_token");
expect(call?.method).toBe("GET");
expect(queryOf(call).get("grant_type")).toBe("ig_refresh_token");
expect(queryOf(call).get("access_token")).toBe(LONG_TOKEN);
// fb_exchange_token must NOT be used as a refresh grant.
expect(
stub.calls.some(
(c) =>
pathOf(c) === "/oauth/access_token" &&
queryOf(c).get("grant_type") === "fb_exchange_token",
),
).toBe(false);
expect(await credentials.getSecret(connection, "access_token")).toBe(REFRESHED_TOKEN);
});
it("maps an expired token to AuthError", async () => {
const { connector, credentials } = await createHarness();
await credentials.put(connection, "access_token", "expired-token");
await expect(connector.refreshCredentials?.(connection)).rejects.toMatchObject({
name: "AuthError",
});
});
});
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({
fetchImpl: stubGraph(stub),
requestsPerSecond: 1000,
sleep: async () => undefined,
credentials,
env: {},
loadAsset: async (assetId) => asset(assetId),
resolveMediaUrl: async (media) => media.storageKey,
});
const publication = await connector.publish?.(
connection,
post("hello instagram", ["asset-1"]),
);
const container = stub.calls.find((c) => pathOf(c) === `/${IG_USER_ID}/media`);
expect(container?.method).toBe("POST");
expect(formBody(container?.init).get("image_url")).toBe("https://cdn.example.com/asset-1.jpg");
expect(formBody(container?.init).get("caption")).toBe("hello instagram");
const publish = stub.calls.find((c) => pathOf(c) === `/${IG_USER_ID}/media_publish`);
expect(publish?.method).toBe("POST");
expect(formBody(publish?.init).get("creation_id")).toMatch(/^container-/);
expect(publication?.remoteId).toMatch(/^media-/);
expect(publication?.remoteUrl).toBe(`https://www.instagram.com/p/${publication?.remoteId}/`);
expect(publication?.workspaceId).toBe("ws-1");
expect(publication?.connectionId).toBe("conn-instagram");
expect(publication?.draftId).toBe("direct");
expect(publication?.publishedAt).toEqual(new Date("2026-10-06T12:00:00+0000"));
expect(publication?.providerMetadata).toMatchObject({ kind: "image", items: 1 });
});
it("carousel: children containers with is_carousel_item, then a CAROUSEL parent", async () => {
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) {
await credentials.put(connection, key, value);
}
const connector = createInstagramConnector({
fetchImpl: stubGraph(stub),
requestsPerSecond: 1000,
sleep: async () => undefined,
credentials,
env: {},
loadAsset: async (assetId) => asset(assetId),
});
const publication = await connector.publish?.(
connection,
post("three photos", ["a", "b", "c"], { draftId: "draft-7" }),
);
const containers = stub.calls.filter((c) => pathOf(c) === `/${IG_USER_ID}/media`);
expect(containers).toHaveLength(4);
const forms = containers.map((c) => formBody(c.init));
expect(forms[0]?.get("is_carousel_item")).toBe("true");
expect(forms[1]?.get("is_carousel_item")).toBe("true");
expect(forms[2]?.get("is_carousel_item")).toBe("true");
expect(forms[0]?.get("image_url")).toBe("https://cdn.example.com/a.jpg");
expect(forms[3]?.get("media_type")).toBe("CAROUSEL");
expect(forms[3]?.get("children")).toMatch(/^container-\d+,container-\d+,container-\d+$/);
expect(forms[3]?.get("caption")).toBe("three photos");
const publish = stub.calls.find((c) => pathOf(c) === `/${IG_USER_ID}/media_publish`);
// Children are container-101..103 (nextId starts at 100); the parent is next.
expect(formBody(publish?.init).get("creation_id")).toBe("container-104");
expect(publication?.draftId).toBe("draft-7");
expect(publication?.providerMetadata).toMatchObject({ kind: "carousel", items: 3 });
});
it("accepts pre-resolved providerMetadata.media URLs", async () => {
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) {
await credentials.put(connection, key, value);
}
const connector = createInstagramConnector({
fetchImpl: stubGraph(stub),
requestsPerSecond: 1000,
sleep: async () => undefined,
credentials,
env: {},
});
const publication = await connector.publish?.(
connection,
post("", [], { media: [{ url: "https://cdn.example.com/presigned.jpg", kind: "image" }] }),
);
const container = stub.calls.find((c) => pathOf(c) === `/${IG_USER_ID}/media`);
expect(formBody(container?.init).get("image_url")).toBe("https://cdn.example.com/presigned.jpg");
expect(publication?.providerMetadata).toMatchObject({ kind: "image" });
});
it("throws CapabilityNotSupportedError for a text-only post", async () => {
const { stub, connector } = await createHarness();
await expect(connector.publish?.(connection, post("no media at all"))).rejects.toMatchObject({
name: "CapabilityNotSupportedError",
});
expect(stub.calls.filter((c) => pathOf(c) === `/${IG_USER_ID}/media`)).toHaveLength(0);
});
it("rejects more than 10 carousel items", async () => {
const { connector } = await createHarness();
const media = Array.from({ length: 11 }, (_, i) => ({ url: `https://cdn.example.com/${i}.jpg`, kind: "image" as const }));
await expect(
connector.publish?.(connection, post("too many", [], { media })),
).rejects.toThrow(/at most 10 carousel items/);
});
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: [],
})),
).rejects.toThrow();
});
it("maps an expired token on publish to AuthError", async () => {
const { connector, credentials } = await createHarness();
await credentials.put(connection, "access_token", "revoked");
await expect(
connector.publish?.(connection, post("x", [], { media: [{ url: "https://cdn.example.com/a.jpg", kind: "image" }] })),
).rejects.toMatchObject({ name: "AuthError" });
});
});
describe("instagram connector: deletePublication", () => {
it("DELETEs the media node", async () => {
const { stub, connector } = await createHarness();
await connector.deletePublication?.(connection, "media-101");
const call = stub.calls.find((c) => c.method === "DELETE");
expect(call?.url.pathname).toBe("/v21.0/media-101");
expect(headerOf(call?.init, "authorization")).toBe(`Bearer ${LONG_TOKEN}`);
});
it("treats 404 as already deleted (idempotent)", async () => {
const fetchImpl: typeof fetch = async (input, init) => {
const href = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
const url = new URL(href);
stub404Calls.push({ method: (init?.method ?? "GET").toUpperCase(), url, init });
if ((init?.method ?? "GET").toUpperCase() === "DELETE") {
return graphError("The media was not found", 100, 404);
}
return graphError("no route", 100, 404);
};
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) {
await credentials.put(connection, key, value);
}
const connector = createInstagramConnector({
fetchImpl,
requestsPerSecond: 1000,
sleep: async () => undefined,
credentials,
env: {},
});
await expect(connector.deletePublication?.(connection, "media-gone")).resolves.toBeUndefined();
});
});
describe("instagram connector: caption rules", () => {
it("accepts a normal caption", () => {
expect(() => assertCaption("hello #world")).not.toThrow();
});
it("rejects captions over 2200 characters", () => {
expect(() => assertCaption("x".repeat(2201))).toThrow(/caption exceeds 2200 characters/);
});
it("rejects more than 30 hashtags", () => {
const caption = Array.from({ length: 31 }, (_, i) => `#tag${i}`).join(" ");
expect(() => assertCaption(caption)).toThrow(/more than 30 hashtags/);
});
});

View file

@ -0,0 +1,726 @@
/**
* Instagram connector — Instagram Graph API via Facebook Login.
*
* Connect flow (two authorization legs, mirroring the Mastodon connector):
* leg 1 builds the Facebook Login dialog URL
* (https://www.facebook.com/v21.0/dialog/oauth, scopes instagram_basic,
* instagram_content_publish, pages_show_list) and throws
* AuthorizationRedirectRequired; leg 2 exchanges the code at
* graph.facebook.com/v21.0/oauth/access_token for a short-lived user token,
* 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.
*
* Honest capability notes (verified against the Graph API):
* - Instagram has no text-only posts. publishText is declared only because
* the SDK couples `publish` to it; a post without media throws
* CapabilityNotSupportedError. The caption (≤ 2200 chars, ≤ 30 hashtags)
* rides the media container.
* - Publishing is two-step and URL-backed: POST /{ig-user-id}/media builds a
* container from a publicly fetchable https image_url (JPEG only), then
* POST /{ig-user-id}/media_publish {creation_id} publishes it. There is no
* binary upload. 2–10 images become carousel children containers plus a
* CAROUSEL parent.
* - Only professional (business/creator) accounts linked to a Facebook Page
* are addressable; personal accounts are invisible to the API and connect
* fails if no Page carries an instagram_business_account.
* - Token refresh: fb_exchange_token is not a valid refresh grant for IG
* tokens, so refreshCredentials uses GET /refresh_access_token with
* grant_type=ig_refresh_token.
* - Deletion: DELETE /{ig-media-id} works only for media the app published;
* a 404 (already gone) counts as deleted.
* - nativeScheduling false (the API has no scheduled media publish),
* analytics false (no insights surface in this connector), no feed,
* comments, DMs, webhooks or amplify.
*
* Rate limits: 25 API-published posts per rolling 24h per account plus
* Graph BUC budgets; the SDK client runs at 0.5 requests/s per host. The
* publishing scopes require Meta App Review before non-role accounts connect.
*/
import { randomUUID } from "node:crypto";
import type {
ConnectorCapabilities,
ConnectorConnection,
CredentialRef,
MediaAsset,
NormalizedIdentity,
NormalizedPost,
Publication,
} from "@tactical-media-broadcast-hub/core";
import {
assertValidManifest,
AuthError,
CapabilityNotSupportedError,
ConnectorError,
InMemoryCredentials,
RateLimitedHttpClient,
RateLimitError,
type AuthorizationRequest,
type AuthorizationResult,
type ConnectorManifest,
type CredentialAccessor,
type PlatformConnector,
} from "@tactical-media-broadcast-hub/connector-sdk";
const PROVIDER = "instagram";
const DEFAULT_API_VERSION = "v21.0";
const CAPTION_LIMIT = 2200;
const HASHTAG_LIMIT = 30;
const CAROUSEL_MAX = 10;
/** API-published posts per rolling 24h per account (Meta-enforced). */
const PUBLISH_QUOTA = 25;
const SCOPES = ["instagram_basic", "instagram_content_publish", "pages_show_list"];
const PAGE_FIELDS = "id,name,instagram_business_account{id,username,name}";
const MEDIA_FIELDS = "id,permalink,timestamp,media_type";
const CAPABILITIES: ConnectorCapabilities = {
// Declared only because the SDK couples `publish` to publishText; text-only
// posts throw CapabilityNotSupportedError (see header).
publishText: true,
publishImages: true,
publishCarousel: true,
publishVideo: false,
feed: false,
mentions: false,
comments: false,
replies: false,
amplifyRepost: false,
amplifyQuote: false,
amplifyShare: false,
directMessages: false,
nativeScheduling: false,
deletePublication: true,
webhooks: false,
reactions: false,
analytics: false,
};
/** Credential keys stored per connection through the CredentialAccessor. */
const SECRET = {
accessToken: "access_token",
igUserId: "ig_user_id",
clientId: "client_id",
clientSecret: "client_secret",
} as const;
export interface InstagramConnectorOptions {
/** fetch override for tests (same pattern as the other connectors). */
fetchImpl?: typeof fetch;
/** Requests per second per API host. Default 0.5; tests raise it. */
requestsPerSecond?: number;
/** Sleep override for the rate limiter (tests). */
sleep?: (ms: number) => Promise<void>;
/** Secret store. Default: in-memory (dev + contract tests). */
credentials?: CredentialAccessor;
/** Graph API version path segment. Default v21.0. */
apiVersion?: string;
/** Facebook app credentials fallback when not supplied per connection. */
env?: Record<string, string | undefined>;
/** Resolve a media asset id to its metadata (needed by publish with media). */
loadAsset?: (assetId: string) => Promise<MediaAsset>;
/**
* Publicly fetchable https URL for an asset (e.g. presigned S3 GET). Meta's
* servers download the bytes from it while building the container. Default:
* the asset's storageKey must already be an absolute http(s) URL.
*/
resolveMediaUrl?: (media: MediaAsset) => Promise<string>;
}
/** The SDK's AuthorizationRequest carries no provider input, so the second
* leg (code exchange) travels in this optional field. */
export interface InstagramAuthorizationRequest extends AuthorizationRequest {
fields?: Record<string, string>;
providerMetadata?: Record<string, unknown>;
}
/**
* Thrown by the first authorize leg. AuthorizationResult has no slot for a
* redirect, so the dialog URL rides on this error; callers send the user
* there and call authorize again with providerMetadata.code.
*/
export class AuthorizationRedirectRequired extends ConnectorError {
constructor(
readonly authorizationUrl: string,
readonly connectionId: string,
) {
super("redirect the user to Facebook to authorize", PROVIDER);
this.name = "AuthorizationRedirectRequired";
}
}
interface GraphErrorBody {
error?: {
message?: string;
type?: string;
code?: number;
error_subcode?: number;
error_user_msg?: string;
};
}
interface GraphList<T> {
data: T[];
}
interface GraphIdResponse {
id?: string;
success?: boolean;
}
interface FacebookUser {
id: string;
name?: string;
}
/** A Facebook Page with (optionally) its linked Instagram professional account. */
interface FacebookPage {
id: string;
name?: string;
instagram_business_account?: {
id: string;
username?: string;
name?: string;
};
}
interface IgMedia {
id: string;
permalink?: string;
timestamp?: string;
media_type?: string;
}
interface IgToken {
access_token?: string;
token_type?: string;
expires_in?: number;
}
function str(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
/** Instagram accepts JPEG images only for image_url containers. */
function assertJpeg(mimeType: string): void {
const normalized = mimeType.toLowerCase();
if (normalized === "image/jpeg" || normalized === "image/jpg") return;
throw new ConnectorError(
`${PROVIDER}: unsupported media type ${mimeType} — Instagram image containers accept JPEG only`,
PROVIDER,
);
}
/** Captions ride the media container; Instagram caps length and hashtags. */
export function assertCaption(caption: string): void {
if ([...caption].length > CAPTION_LIMIT) {
throw new ConnectorError(`${PROVIDER}: caption exceeds ${CAPTION_LIMIT} characters`, PROVIDER);
}
const hashtags = caption.match(/(^|\s)#[\p{L}\p{N}_]+/gu) ?? [];
if (hashtags.length > HASHTAG_LIMIT) {
throw new ConnectorError(`${PROVIDER}: caption has more than ${HASHTAG_LIMIT} hashtags`, PROVIDER);
}
}
function parseJson(text: string): unknown {
if (text.length === 0) return {};
try {
return JSON.parse(text) as unknown;
} catch (cause) {
throw new ConnectorError(`${PROVIDER}: Graph API returned non-JSON`, PROVIDER, cause);
}
}
function statusOf(error: unknown): number | undefined {
return error instanceof ConnectorError ? (error.cause as { status?: number } | undefined)?.status : undefined;
}
/** Asset storageKey as public URL — presign via resolveMediaUrl otherwise. */
function publicAssetUrl(media: MediaAsset): string {
let parsed: URL;
try {
parsed = new URL(media.storageKey);
} catch (cause) {
throw new ConnectorError(
`${PROVIDER}: URL-based publish needs an absolute public https image_url (got storageKey "${media.storageKey}") — pass resolveMediaUrl to presign`,
PROVIDER,
cause,
);
}
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
throw new ConnectorError(`${PROVIDER}: image_url must be http(s), got ${parsed.protocol}`, PROVIDER);
}
return parsed.href;
}
export function createInstagramConnector(options: InstagramConnectorOptions = {}): PlatformConnector {
const client = new RateLimitedHttpClient({
requestsPerSecond: options.requestsPerSecond ?? 0.5,
fetchImpl: options.fetchImpl,
sleep: options.sleep,
});
const credentials = options.credentials ?? new InMemoryCredentials();
const env = options.env ?? {};
const apiVersion = options.apiVersion ?? DEFAULT_API_VERSION;
const dialogBase = `https://www.facebook.com/${apiVersion}`;
const graphBase = `https://graph.facebook.com/${apiVersion}`;
const manifest: ConnectorManifest = {
id: PROVIDER,
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)",
required: false,
secret: false,
pattern: "^[0-9]+$",
placeholder: "17841400000000000",
},
],
oauth: {
kind: "oauth2-redirect",
authorizeUrlTemplate: `${dialogBase}/dialog/oauth`,
scopes: SCOPES,
pkce: false,
},
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 {
return await credentials.getAccessToken(connection);
} catch {
const field = str(connection.fields.accessToken);
if (field !== undefined) return field;
throw new AuthError(`${PROVIDER}: no access token for connection ${connection.id}`, PROVIDER, false);
}
}
async function storedIgUserId(connection: ConnectorConnection): Promise<string> {
try {
return await credentials.getSecret(connection, SECRET.igUserId);
} catch {
const field = str(connection.fields.igUserId);
if (field !== undefined) return field;
throw new ConnectorError(`${PROVIDER}: connection ${connection.id} has no ig_user_id`, PROVIDER);
}
}
async function secretOrUndefined(connection: ConnectorConnection, key: string): Promise<string | undefined> {
try {
return await credentials.getSecret(connection, key);
} catch {
return undefined;
}
}
/** 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);
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,
);
}
return { clientId, clientSecret };
}
/** Graph errors → SDK taxonomy. Meta reports throttling as 4xx with codes. */
function graphError(response: Response, method: string, url: URL, body: unknown): Error {
const error = (body as GraphErrorBody).error ?? {};
const detail = error.error_user_msg ?? error.message ?? "Graph API request failed";
const message = `HTTP ${response.status} ${method} ${url.pathname}: ${detail}`;
const code = error.code;
// 4/17/32/613: app/user/page/custom throttles; 9: publish quota.
if (code === 4 || code === 17 || code === 32 || code === 613 || code === 9) {
return new RateLimitError(PROVIDER);
}
if (code === 190 || response.status === 401) return new AuthError(message, PROVIDER, false);
if (code === 10 || (code !== undefined && code >= 200 && code < 300)) {
return new AuthError(`${message} (permission missing — App Review scope?)`, PROVIDER, false);
}
return new ConnectorError(message, PROVIDER, { status: response.status, code });
}
async function graph<T>(
connection: ConnectorConnection,
method: "GET" | "POST" | "DELETE",
path: string,
params: Record<string, string> = {},
): Promise<T> {
const url = new URL(`${graphBase}${path}`);
const headers: Record<string, string> = {
accept: "application/json",
authorization: `Bearer ${await tokenOf(connection)}`,
};
const init: RequestInit = { method, headers };
if (method === "POST") {
headers["content-type"] = "application/x-www-form-urlencoded";
init.body = new URLSearchParams(params).toString();
} else {
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
}
const response = await client.request(PROVIDER, url.href, init);
const body = parseJson(await response.text());
if (!response.ok) throw graphError(response, method, url, body);
return body as T;
}
/** App-level (pre-connection) GET on the OAuth token endpoint. */
async function tokenRequest<T>(params: Record<string, string>): Promise<T> {
const url = new URL(`${graphBase}/oauth/access_token`);
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
const response = await client.request(PROVIDER, url.href, { headers: { accept: "application/json" } });
const body = parseJson(await response.text());
if (!response.ok) throw graphError(response, "GET", url, body);
return body as T;
}
/**
* GET /me validates the token (it is the Facebook user under Facebook
* Login), then GET /me/accounts resolves the first Page carrying an
* instagram_business_account — the IG professional account to publish as.
*/
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;
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`,
PROVIDER,
);
}
const username = linked.username ?? linked.id;
const identity: NormalizedIdentity = {
provider: PROVIDER,
remoteId: linked.id,
handle: `@${username}`,
};
if (linked.name !== undefined) identity.displayName = linked.name;
return identity;
}
/** providerMetadata.media: pre-resolved public https URLs (e.g. presigned). */
function preResolved(meta: Record<string, unknown>): string[] {
if (!Array.isArray(meta.media)) return [];
return (meta.media as unknown[])
.map((entry) => (typeof entry === "object" && entry !== null ? str((entry as Record<string, unknown>)["url"]) : undefined))
.filter((url): url is string => url !== undefined);
}
async function resolveAsset(assetId: string): Promise<string> {
if (options.loadAsset === undefined) {
throw new ConnectorError(`${PROVIDER}: publishing mediaAssetIds needs a loadAsset option`, PROVIDER);
}
const asset = await options.loadAsset(assetId);
assertJpeg(asset.mimeType);
return options.resolveMediaUrl !== undefined
? await options.resolveMediaUrl(asset)
: publicAssetUrl(asset);
}
async function createContainer(connection: ConnectorConnection, params: Record<string, string>): Promise<string> {
const result = await graph<GraphIdResponse>(
connection,
"POST",
`/${await storedIgUserId(connection)}/media`,
params,
);
if (result.id === undefined) {
throw new ConnectorError(`${PROVIDER}: media container creation returned no id`, PROVIDER);
}
return result.id;
}
async function publishContainer(
connection: ConnectorConnection,
creationId: string,
draftId: string,
providerMetadata: Record<string, unknown>,
): Promise<Publication> {
const user = await storedIgUserId(connection);
const published = await graph<GraphIdResponse>(
connection,
"POST",
`/${user}/media_publish`,
{ creation_id: creationId },
);
if (published.id === undefined) {
throw new ConnectorError(`${PROVIDER}: media_publish returned no media id`, PROVIDER);
}
const media = await graph<IgMedia>(connection, "GET", `/${encodeURIComponent(published.id)}`, {
fields: MEDIA_FIELDS,
});
const publication: Publication = {
id: randomUUID(),
workspaceId: connection.workspaceId,
draftId,
connectionId: connection.id,
remoteId: published.id,
publishedAt: media.timestamp !== undefined ? new Date(media.timestamp) : new Date(),
providerMetadata: { creationId, mediaType: media.media_type, publishQuota: PUBLISH_QUOTA, ...providerMetadata },
};
if (media.permalink !== undefined) publication.remoteUrl = media.permalink;
return publication;
}
const connector: PlatformConnector = {
manifest: () => {
assertValidManifest(manifest);
return manifest;
},
async buildAuthorizationUrl(input: InstagramAuthorizationRequest) {
try {
await connector.authorize(input);
throw new ConnectorError("authorization redirect missing", PROVIDER);
} catch (error) {
if (!(error instanceof AuthorizationRedirectRequired)) throw error;
const connection: ConnectorConnection = {
id: error.connectionId,
workspaceId: input.workspaceId,
provider: PROVIDER,
channelId: error.connectionId,
credentialId: { kind: "credential-ref", id: error.connectionId },
fields: input.fields?.clientId !== undefined ? { clientId: input.fields.clientId } : {},
createdAt: new Date(),
};
return { authorizationUrl: error.authorizationUrl, connection };
}
},
async exchangeCode(input: CodeExchangeRequest): Promise<AuthorizationResult> {
return connector.authorize({
...input,
providerMetadata: { code: input.code },
} as InstagramAuthorizationRequest);
},
/**
* Leg 1 (no code): validate app credentials, store them in the vault 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
* /me + /me/accounts and store access_token + ig_user_id.
*/
async authorize(input: InstagramAuthorizationRequest): Promise<AuthorizationResult> {
const meta = input.providerMetadata ?? {};
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 },
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);
if (code === undefined) {
const url = new URL(`${dialogBase}/dialog/oauth`);
url.searchParams.set("client_id", client.clientId);
url.searchParams.set("redirect_uri", input.redirectUri);
url.searchParams.set("response_type", "code");
url.searchParams.set("scope", SCOPES.join(","));
url.searchParams.set("state", input.state);
throw new AuthorizationRedirectRequired(url.toString(), connectionId);
}
const shortLived = await tokenRequest<IgToken>({
client_id: client.clientId,
client_secret: client.clientSecret,
redirect_uri: input.redirectUri,
code,
});
if (shortLived.access_token === undefined) {
throw new ConnectorError(`${PROVIDER}: code exchange returned no access token`, PROVIDER);
}
// fb_exchange_token upgrades the short-lived token to ~60 days; it is
// NOT usable later as a refresh grant for IG tokens (see refreshCredentials).
const longLived = await tokenRequest<IgToken>({
grant_type: "fb_exchange_token",
client_id: client.clientId,
client_secret: client.clientSecret,
fb_exchange_token: shortLived.access_token,
});
if (longLived.access_token === undefined) {
throw new ConnectorError(`${PROVIDER}: long-lived token exchange returned no access token`, PROVIDER);
}
await credentials.put(connection, SECRET.accessToken, longLived.access_token);
const identity = await fetchIdentity(connection);
await credentials.put(connection, SECRET.igUserId, identity.remoteId);
connection.fields.igUserId = identity.remoteId;
return { connection, identity };
},
/**
* IG tokens cannot be refreshed with grant_type=fb_exchange_token (that
* grant only upgrades a short-lived token and is refused for IG-scoped
* tokens), so the documented refresh is
* GET /refresh_access_token?grant_type=ig_refresh_token.
*/
async refreshCredentials(connection: ConnectorConnection): Promise<CredentialRef> {
const url = new URL(`${graphBase}/refresh_access_token`);
url.searchParams.set("grant_type", "ig_refresh_token");
url.searchParams.set("access_token", await tokenOf(connection));
const response = await client.request(PROVIDER, url.href, { headers: { accept: "application/json" } });
const body = parseJson(await response.text());
if (!response.ok) throw graphError(response, "GET", url, body);
const refreshed = body as IgToken;
if (refreshed.access_token === undefined) {
throw new AuthError(`${PROVIDER}: token refresh returned no access token`, PROVIDER, false);
}
await credentials.put(connection, SECRET.accessToken, refreshed.access_token);
return connection.credentialId;
},
async getIdentity(connection: ConnectorConnection): Promise<NormalizedIdentity> {
if (str(connection.fields.igUserId) === undefined && (await secretOrUndefined(connection, SECRET.igUserId)) === undefined) {
return fetchIdentity(connection);
}
// Known account id: confirm the token still reaches it via /me/accounts.
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 igUserId = await storedIgUserId(connection);
const linked = data
.map((page) => page.instagram_business_account)
.find((account) => account !== undefined && account.id === igUserId);
if (linked === undefined) {
throw new ConnectorError(
`${PROVIDER}: Instagram account ${igUserId} is no longer reachable through the Pages of Facebook user ${me.id}`,
PROVIDER,
);
}
const identity: NormalizedIdentity = {
provider: PROVIDER,
remoteId: linked.id,
handle: `@${linked.username ?? linked.id}`,
};
if (linked.name !== undefined) identity.displayName = linked.name;
return identity;
},
/**
* Images only: one image → IMAGE container; 2–10 → carousel children
* containers plus a CAROUSEL parent; then /media_publish. The caption
* rides the (parent) container. providerMetadata.media supplies
* pre-resolved URLs; mediaAssetIds go through loadAsset + resolveMediaUrl.
*/
async publish(connection: ConnectorConnection, post: NormalizedPost): Promise<Publication> {
const meta = post.providerMetadata;
if (mediaLength(post, meta) === 0) {
throw new CapabilityNotSupportedError(
PROVIDER,
"publishText (Instagram has no text-only posts — attach at least one image)",
);
}
const urls = [...preResolved(meta)];
for (const assetId of post.mediaAssetIds) urls.push(await resolveAsset(assetId));
if (urls.length > CAROUSEL_MAX) {
throw new ConnectorError(`${PROVIDER}: at most ${CAROUSEL_MAX} carousel items`, PROVIDER);
}
if (post.text.length > 0) assertCaption(post.text);
const draftId = str(meta.draftId) ?? "direct";
let creationId: string;
if (urls.length === 1) {
const params: Record<string, string> = { image_url: urls[0] as string };
if (post.text.length > 0) params.caption = post.text;
creationId = await createContainer(connection, params);
} else {
const children: string[] = [];
for (const url of urls) {
children.push(await createContainer(connection, { image_url: url, is_carousel_item: "true" }));
}
const parentParams: Record<string, string> = { media_type: "CAROUSEL", children: children.join(",") };
if (post.text.length > 0) parentParams.caption = post.text;
creationId = await createContainer(connection, parentParams);
}
return publishContainer(connection, creationId, draftId, {
kind: urls.length === 1 ? "image" : "carousel",
items: urls.length,
});
},
/** DELETE /{ig-media-id}; only app-published media is deletable. 404 → gone. */
async deletePublication(connection: ConnectorConnection, remoteId: string): Promise<void> {
try {
await graph(connection, "DELETE", `/${encodeURIComponent(remoteId)}`);
} catch (error) {
// Already gone counts as deleted — deletion is idempotent.
if (statusOf(error) !== 404) throw error;
}
},
/** No binary upload exists — return the public URL the container would use. */
async uploadMedia(connection: ConnectorConnection, media: MediaAsset): Promise<RemoteMediaShape> {
assertJpeg(media.mimeType);
const url = options.resolveMediaUrl !== undefined
? await options.resolveMediaUrl(media)
: publicAssetUrl(media);
const containerId = await createContainer(connection, { image_url: url });
const remote: RemoteMediaShape = { remoteId: containerId, url, mimeType: media.mimeType };
if (media.width !== undefined) remote.width = media.width;
if (media.height !== undefined) remote.height = media.height;
return remote;
},
};
return connector;
}
/** Publishing needs at least one media item — count both sources cheaply. */
function mediaLength(post: NormalizedPost, meta: Record<string, unknown>): number {
const pre = Array.isArray(meta.media) ? (meta.media as unknown[]).length : 0;
return pre + post.mediaAssetIds.length;
}
type RemoteMediaShape = import("@tactical-media-broadcast-hub/core").RemoteMedia;
type CodeExchangeRequest = import("@tactical-media-broadcast-hub/connector-sdk").CodeExchangeRequest;

View file

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}