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:
commit
aa5e262f3a
880 changed files with 111042 additions and 0 deletions
447
packages/connector-mastodon/src/contract.test.ts
Normal file
447
packages/connector-mastodon/src/contract.test.ts
Normal file
|
|
@ -0,0 +1,447 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { ConnectorConnection } from "@tactical-media-broadcast-hub/core";
|
||||
import { CapabilityNotSupportedError, InMemoryCredentials } from "@tactical-media-broadcast-hub/connector-sdk";
|
||||
import { connectorContractTests } from "@tactical-media-broadcast-hub/connector-sdk/testing";
|
||||
import {
|
||||
AuthorizationRedirectRequired,
|
||||
createMastodonConnector,
|
||||
type MastodonAuthorizationRequest,
|
||||
} from "./index.js";
|
||||
|
||||
/**
|
||||
* Stubbed Mastodon instance: 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;
|
||||
}
|
||||
|
||||
interface StubState {
|
||||
calls: StubCall[];
|
||||
nextStatusId: number;
|
||||
/** Idempotency-Key → status id, like the instance's one-hour dedupe cache. */
|
||||
idempotency?: Map<string, string>;
|
||||
/** Statuses actually created (side effects, independent of responses). */
|
||||
created?: string[];
|
||||
}
|
||||
|
||||
const INSTANCE = "https://mastodon.example";
|
||||
const HOST = "mastodon.example";
|
||||
const TOKEN = "stub-access-token";
|
||||
const REDIRECT = "https://app.example.com/oauth/mastodon/callback";
|
||||
|
||||
const ACCOUNT = {
|
||||
id: "109000000000000001",
|
||||
username: "anarchoposter",
|
||||
acct: "anarchoposter",
|
||||
display_name: "AnarchoPoster",
|
||||
avatar: `${INSTANCE}/avatars/original/missing.png`,
|
||||
url: `${INSTANCE}/@anarchoposter`,
|
||||
};
|
||||
|
||||
const OTHER = {
|
||||
id: "109000000000000002",
|
||||
username: "alice",
|
||||
acct: "alice@other.example",
|
||||
display_name: "Alice",
|
||||
avatar: "",
|
||||
url: "https://other.example/@alice",
|
||||
};
|
||||
|
||||
const connection: ConnectorConnection = {
|
||||
id: "conn-mastodon",
|
||||
workspaceId: "ws-1",
|
||||
provider: "mastodon",
|
||||
channelId: "conn-mastodon",
|
||||
credentialId: { kind: "credential-ref", id: "cred-1" },
|
||||
fields: { instanceUrl: HOST },
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
function status(
|
||||
id: string,
|
||||
overrides: Partial<Record<string, unknown>> = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
id,
|
||||
created_at: "2026-10-06T12:00:00.000Z",
|
||||
url: `${INSTANCE}/@tactical-media-broadcast-hub/${id}`,
|
||||
uri: `${INSTANCE}/users/anarchoposter/statuses/${id}`,
|
||||
content: "<p>hello</p>",
|
||||
visibility: "public",
|
||||
account: ACCOUNT,
|
||||
media_attachments: [],
|
||||
mentions: [],
|
||||
reblogs_count: 0,
|
||||
replies_count: 0,
|
||||
favourites_count: 0,
|
||||
in_reply_to_id: null,
|
||||
reblog: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function json(body: unknown, init: ResponseInit = {}): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
...init,
|
||||
headers: { "content-type": "application/json", ...init.headers },
|
||||
});
|
||||
}
|
||||
|
||||
function jsonBody(init: RequestInit | undefined): Record<string, unknown> {
|
||||
return JSON.parse(String(init?.body ?? "{}")) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function formBody(init: RequestInit | undefined): URLSearchParams {
|
||||
return new URLSearchParams(String(init?.body ?? ""));
|
||||
}
|
||||
|
||||
function headerOf(init: RequestInit | undefined, name: string): string | undefined {
|
||||
return new Headers(init?.headers).get(name) ?? undefined;
|
||||
}
|
||||
|
||||
function stubInstance(state: StubState): typeof fetch {
|
||||
return async (input: Parameters<typeof fetch>[0], init?: RequestInit): Promise<Response> => {
|
||||
const raw = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
||||
const url = new URL(raw);
|
||||
const method = (init?.method ?? "GET").toUpperCase();
|
||||
state.calls.push({ method, url, init });
|
||||
const route = `${method} ${url.pathname}`;
|
||||
|
||||
if (route === "POST /api/v1/apps") {
|
||||
return json({
|
||||
id: "563419",
|
||||
name: formBody(init).get("client_name"),
|
||||
client_id: "stub-client-id",
|
||||
client_secret: "stub-client-secret",
|
||||
redirect_uri: formBody(init).get("redirect_uris"),
|
||||
});
|
||||
}
|
||||
if (route === "POST /oauth/token") {
|
||||
const form = formBody(init);
|
||||
if (form.get("code") !== "good-code") {
|
||||
return json({ error: "invalid_grant", error_description: "The provided authorization grant is invalid" }, { status: 400 });
|
||||
}
|
||||
return json({ access_token: TOKEN, token_type: "Bearer", scope: "read write:statuses write:media", created_at: 1_760_000_000 });
|
||||
}
|
||||
|
||||
// Everything below is an authenticated API call.
|
||||
if (headerOf(init, "authorization") !== `Bearer ${TOKEN}`) {
|
||||
return json({ error: "The access token is invalid" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (route === "GET /api/v1/accounts/verify_credentials") return json(ACCOUNT);
|
||||
|
||||
if (route === "POST /api/v1/statuses") {
|
||||
const body = jsonBody(init);
|
||||
const text = String(body["status"]);
|
||||
if (text === "rate-limit-me") return json({ error: "Too many requests" }, { status: 429, headers: { "retry-after": "30" } });
|
||||
if (text === "server-error-me") return json({ error: "Internal" }, { status: 500 });
|
||||
if (text === "invalid-me") return json({ error: "Validation failed: Text can't be blank" }, { status: 422 });
|
||||
const key = headerOf(init, "idempotency-key");
|
||||
const seen = key === undefined ? undefined : state.idempotency?.get(key);
|
||||
if (seen !== undefined) return json(status(seen, { content: `<p>${text}</p>` }));
|
||||
const id = String((state.nextStatusId += 1));
|
||||
if (key !== undefined) state.idempotency?.set(key, id);
|
||||
state.created?.push(id);
|
||||
if (typeof body["scheduled_at"] === "string") {
|
||||
return json({ id: `s${id}`, scheduled_at: body["scheduled_at"], params: { text: body["status"] }, media_attachments: [] });
|
||||
}
|
||||
return json(
|
||||
status(id, {
|
||||
content: `<p>${String(body["status"])}</p>`,
|
||||
visibility: body["visibility"] ?? "public",
|
||||
...(typeof body["quoted_status_id"] === "string"
|
||||
? { quote: { state: "accepted", quoted_status: status(body["quoted_status_id"], { account: OTHER }) } }
|
||||
: {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (route === "GET /api/v1/timelines/home") {
|
||||
return json(
|
||||
[
|
||||
status("300", {
|
||||
account: OTHER,
|
||||
content: "<p>first & foremost<br>line two</p>",
|
||||
media_attachments: [
|
||||
{ id: "m1", type: "image", url: "https://files.example/m1.png", preview_url: null },
|
||||
],
|
||||
reblogs_count: 2,
|
||||
replies_count: 1,
|
||||
favourites_count: 5,
|
||||
}),
|
||||
status("299", { reblog: status("250", { account: OTHER, content: "<p>boosted</p>" }) }),
|
||||
],
|
||||
{ headers: { link: `<${INSTANCE}/api/v1/timelines/home?max_id=299>; rel="next", <${INSTANCE}/api/v1/timelines/home?min_id=300>; rel="prev"` } },
|
||||
);
|
||||
}
|
||||
|
||||
const reblog = /^POST \/api\/v1\/statuses\/([^/]+)\/reblog$/.exec(route);
|
||||
if (reblog?.[1] !== undefined) {
|
||||
const original = status(decodeURIComponent(reblog[1]), { account: OTHER });
|
||||
return json(status(String((state.nextStatusId += 1)), { reblog: original, content: "" }));
|
||||
}
|
||||
|
||||
return json({ error: `stub: no route for ${route}` }, { status: 404 });
|
||||
};
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
state: StubState;
|
||||
connector: ReturnType<typeof createMastodonConnector>;
|
||||
credentials: InMemoryCredentials;
|
||||
}
|
||||
|
||||
async function createHarness(seedToken = true): Promise<Harness> {
|
||||
const state: StubState = { calls: [], nextStatusId: 1000, idempotency: new Map(), created: [] };
|
||||
const credentials = new InMemoryCredentials();
|
||||
if (seedToken) await credentials.put(connection, "access_token", TOKEN);
|
||||
const connector = createMastodonConnector({
|
||||
fetchImpl: stubInstance(state),
|
||||
requestsPerSecond: 1000,
|
||||
sleep: async () => Promise.resolve(),
|
||||
credentials,
|
||||
});
|
||||
return { state, connector, credentials };
|
||||
}
|
||||
|
||||
const leg1: MastodonAuthorizationRequest = {
|
||||
redirectUri: REDIRECT,
|
||||
workspaceId: "ws-1",
|
||||
state: "csrf-1",
|
||||
fields: { instanceUrl: HOST },
|
||||
};
|
||||
|
||||
const post = (text: string) => ({
|
||||
text,
|
||||
mediaAssetIds: [],
|
||||
visibility: "public" as const,
|
||||
providerMetadata: {},
|
||||
});
|
||||
|
||||
connectorContractTests((await createHarness()).connector, { connection });
|
||||
|
||||
describe("mastodon connector: authorize + identity", () => {
|
||||
it("leg 1 registers an app on the instance and redirects to its consent page", async () => {
|
||||
const { state, connector } = await createHarness(false);
|
||||
const error = await connector
|
||||
.authorize(leg1)
|
||||
.then(
|
||||
() => undefined,
|
||||
(e: unknown) => e,
|
||||
);
|
||||
expect(error).toBeInstanceOf(AuthorizationRedirectRequired);
|
||||
const redirect = error as AuthorizationRedirectRequired;
|
||||
const consent = new URL(redirect.authorizationUrl);
|
||||
expect(consent.origin + consent.pathname).toBe(`${INSTANCE}/oauth/authorize`);
|
||||
expect(consent.searchParams.get("client_id")).toBe("stub-client-id");
|
||||
expect(consent.searchParams.get("redirect_uri")).toBe(REDIRECT);
|
||||
expect(consent.searchParams.get("state")).toBe("csrf-1");
|
||||
expect(redirect.connectionId).toBe(`mastodon:ws-1:${HOST}`);
|
||||
|
||||
const app = state.calls.find((c) => c.url.pathname === "/api/v1/apps");
|
||||
expect(app?.method).toBe("POST");
|
||||
expect(formBody(app?.init).get("scopes")).toBe("read write:statuses write:media");
|
||||
});
|
||||
|
||||
it("leg 2 exchanges the code and the identity roundtrips through getIdentity", async () => {
|
||||
const { state, connector } = await createHarness(false);
|
||||
await expect(connector.authorize(leg1)).rejects.toBeInstanceOf(AuthorizationRedirectRequired);
|
||||
|
||||
const leg2: MastodonAuthorizationRequest = { ...leg1, providerMetadata: { code: "good-code" } };
|
||||
const result = await connector.authorize(leg2);
|
||||
const token = state.calls.find((c) => c.url.pathname === "/oauth/token");
|
||||
const form = formBody(token?.init);
|
||||
expect(form.get("grant_type")).toBe("authorization_code");
|
||||
expect(form.get("client_id")).toBe("stub-client-id");
|
||||
expect(form.get("client_secret")).toBe("stub-client-secret");
|
||||
// Client registered once in leg 1, reused from the vault in leg 2.
|
||||
expect(state.calls.filter((c) => c.url.pathname === "/api/v1/apps")).toHaveLength(1);
|
||||
|
||||
expect(result.connection.fields.instanceUrl).toBe(INSTANCE);
|
||||
expect(result.identity).toEqual({
|
||||
provider: "mastodon",
|
||||
remoteId: ACCOUNT.id,
|
||||
handle: `@anarchoposter@${HOST}`,
|
||||
displayName: "AnarchoPoster",
|
||||
avatarUrl: ACCOUNT.avatar,
|
||||
});
|
||||
expect(await connector.getIdentity(result.connection)).toEqual(result.identity);
|
||||
});
|
||||
|
||||
it("maps a rejected code to a ConnectorError with the instance's description", async () => {
|
||||
const { connector } = await createHarness(false);
|
||||
const request: MastodonAuthorizationRequest = {
|
||||
...leg1,
|
||||
fields: { instanceUrl: HOST, clientId: "cid", clientSecret: "csecret" },
|
||||
providerMetadata: { code: "bad-code" },
|
||||
};
|
||||
await expect(connector.authorize(request)).rejects.toThrow(/authorization grant is invalid/);
|
||||
});
|
||||
|
||||
it("getIdentity sends the bearer token and caches verify_credentials", async () => {
|
||||
const { state, connector } = await createHarness();
|
||||
const first = await connector.getIdentity(connection);
|
||||
const second = await connector.getIdentity(connection);
|
||||
expect(second).toEqual(first);
|
||||
const calls = state.calls.filter((c) => c.url.pathname === "/api/v1/accounts/verify_credentials");
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(headerOf(calls[0]?.init, "authorization")).toBe(`Bearer ${TOKEN}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mastodon connector: publish", () => {
|
||||
it("returns a Publication carrying the status id and url", async () => {
|
||||
const { state, connector } = await createHarness();
|
||||
const publication = await connector.publish?.(connection, post("hello fediverse"));
|
||||
const call = state.calls.find((c) => c.url.pathname === "/api/v1/statuses");
|
||||
expect(call?.method).toBe("POST");
|
||||
expect(jsonBody(call?.init)).toEqual({ status: "hello fediverse", visibility: "public" });
|
||||
expect(publication?.remoteId).toBe("1001");
|
||||
expect(publication?.remoteUrl).toBe(`${INSTANCE}/@tactical-media-broadcast-hub/1001`);
|
||||
expect(publication?.workspaceId).toBe("ws-1");
|
||||
expect(publication?.connectionId).toBe("conn-mastodon");
|
||||
expect(publication?.draftId).toBe("direct");
|
||||
expect(publication?.publishedAt).toEqual(new Date("2026-10-06T12:00:00.000Z"));
|
||||
expect(publication?.providerMetadata).toMatchObject({ visibility: "public" });
|
||||
});
|
||||
|
||||
it("surfaces a far-future schedule as provider_scheduled with a scheduled: remote id", async () => {
|
||||
const { connector } = await createHarness();
|
||||
const scheduledAt = new Date(Date.now() + 60 * 60 * 1000);
|
||||
const publication = await connector.publish?.(connection, {
|
||||
...post("later"),
|
||||
scheduledAt,
|
||||
providerMetadata: { draftId: "draft-from-user" },
|
||||
});
|
||||
expect(publication?.remoteId).toBe("scheduled:s1001");
|
||||
expect(publication?.publishedAt).toEqual(scheduledAt);
|
||||
expect(publication?.providerMetadata).toMatchObject({
|
||||
outcome: "provider_scheduled",
|
||||
scheduled: true,
|
||||
scheduledStatusId: "s1001",
|
||||
});
|
||||
// Caller metadata never becomes the publication's draft identity.
|
||||
expect(publication?.draftId).toBe("direct");
|
||||
});
|
||||
|
||||
it("sends the injected idempotency key and replays to the same status", async () => {
|
||||
const { state, connector } = await createHarness();
|
||||
const key = "wp3:ws-1:draft-1:a1:publish";
|
||||
const withKey = { ...post("once"), providerMetadata: { idempotencyKey: key, draftId: "draft-1" } };
|
||||
const first = await connector.publish?.(connection, withKey);
|
||||
const replay = await connector.publish?.(connection, withKey);
|
||||
const posts = state.calls.filter((c) => c.method === "POST" && c.url.pathname === "/api/v1/statuses");
|
||||
expect(posts.map((c) => headerOf(c.init, "idempotency-key"))).toEqual([key, key]);
|
||||
expect(replay?.remoteId).toBe(first?.remoteId);
|
||||
expect(state.created).toEqual(["1001"]);
|
||||
expect(first?.providerMetadata).toMatchObject({ outcome: "published", idempotencyKey: key });
|
||||
expect(first?.draftId).toBe("direct");
|
||||
});
|
||||
|
||||
it("never resends a status POST on 5xx and reports unknown delivery", async () => {
|
||||
const { state, connector } = await createHarness();
|
||||
await expect(connector.publish?.(connection, post("server-error-me"))).rejects.toMatchObject({
|
||||
name: "ProviderServerError",
|
||||
status: 500,
|
||||
delivery: "unknown",
|
||||
});
|
||||
expect(state.calls.filter((c) => c.url.pathname === "/api/v1/statuses")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("surfaces a status POST 429 as a single-call RateLimitError with Retry-After", async () => {
|
||||
const { state, connector } = await createHarness();
|
||||
await expect(connector.publish?.(connection, post("rate-limit-me"))).rejects.toMatchObject({
|
||||
name: "RateLimitError",
|
||||
retryAfterMs: 30_000,
|
||||
delivery: "rejected",
|
||||
});
|
||||
expect(state.calls.filter((c) => c.url.pathname === "/api/v1/statuses")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("maps a validation 422 to ProviderRejectedError", async () => {
|
||||
const { connector } = await createHarness();
|
||||
await expect(connector.publish?.(connection, post("invalid-me"))).rejects.toMatchObject({
|
||||
name: "ProviderRejectedError",
|
||||
status: 422,
|
||||
delivery: "rejected",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps a 401 to AuthError", async () => {
|
||||
const { connector, credentials } = await createHarness();
|
||||
await credentials.put(connection, "access_token", "revoked");
|
||||
await expect(connector.publish?.(connection, post("x"))).rejects.toMatchObject({ name: "AuthError" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("mastodon connector: feed", () => {
|
||||
it("home returns a Page<FeedItem> with plain text, unwrapped reblogs and a Link cursor", async () => {
|
||||
const { state, connector } = await createHarness();
|
||||
const page = await connector.getFeed?.(connection, { kind: "home", limit: 20, cursor: "400" });
|
||||
const call = state.calls.find((c) => c.url.pathname === "/api/v1/timelines/home");
|
||||
expect(call?.url.searchParams.get("limit")).toBe("20");
|
||||
expect(call?.url.searchParams.get("max_id")).toBe("400");
|
||||
|
||||
expect(page?.items).toHaveLength(2);
|
||||
expect(page?.nextCursor).toBe("299");
|
||||
const [first, boosted] = page?.items ?? [];
|
||||
expect(first).toEqual({
|
||||
id: "300",
|
||||
provider: "mastodon",
|
||||
remoteId: "300",
|
||||
authorHandle: "alice@other.example",
|
||||
text: "first & foremost\nline two",
|
||||
mediaUrls: ["https://files.example/m1.png"],
|
||||
createdAt: new Date("2026-10-06T12:00:00.000Z"),
|
||||
metrics: { reblogs: 2, replies: 1, favourites: 5 },
|
||||
});
|
||||
// The wrapper keeps its own id; content comes from the reblogged original.
|
||||
expect(boosted?.id).toBe("299");
|
||||
expect(boosted?.remoteId).toBe("250");
|
||||
expect(boosted?.text).toBe("boosted");
|
||||
});
|
||||
|
||||
it("refuses search queries that are not a single hashtag", async () => {
|
||||
const { connector } = await createHarness();
|
||||
await expect(
|
||||
connector.getFeed?.(connection, { kind: "search", query: "two words", limit: 10 }),
|
||||
).rejects.toThrow(/single hashtag/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mastodon connector: amplify", () => {
|
||||
it("repost reblogs the target and returns a Publication", async () => {
|
||||
const { state, connector } = await createHarness();
|
||||
const publication = await connector.amplify?.(connection, { kind: "repost", targetRemoteId: "250" });
|
||||
const call = state.calls.find((c) => c.url.pathname === "/api/v1/statuses/250/reblog");
|
||||
expect(call?.method).toBe("POST");
|
||||
expect(publication?.remoteId).toBe("1001");
|
||||
expect(publication?.draftId).toBe("amplify:repost");
|
||||
expect(publication?.remoteUrl).toBe(`${INSTANCE}/@tactical-media-broadcast-hub/1001`);
|
||||
expect(publication?.providerMetadata).toMatchObject({ amplifies: "250", kind: "repost" });
|
||||
});
|
||||
|
||||
it("quote posts with quoted_status_id on a 4.5+ instance", async () => {
|
||||
const { state, connector } = await createHarness();
|
||||
const publication = await connector.amplify?.(connection, {
|
||||
kind: "quote",
|
||||
targetRemoteId: "250",
|
||||
commentary: "worth a read",
|
||||
});
|
||||
const call = state.calls.find((c) => c.url.pathname === "/api/v1/statuses");
|
||||
expect(jsonBody(call?.init)).toEqual({ status: "worth a read", quoted_status_id: "250" });
|
||||
expect(publication?.providerMetadata).toMatchObject({ kind: "quote", quoteState: "accepted" });
|
||||
});
|
||||
|
||||
it("share is not a Mastodon amplification kind", async () => {
|
||||
const { connector } = await createHarness();
|
||||
await expect(
|
||||
connector.amplify?.(connection, { kind: "share", targetRemoteId: "250" }),
|
||||
).rejects.toBeInstanceOf(CapabilityNotSupportedError);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue