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
355
packages/connector-runtime/src/publish.test.ts
Normal file
355
packages/connector-runtime/src/publish.test.ts
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { ConnectorConnection, NormalizedPost, Publication } from "@tactical-media-broadcast-hub/core";
|
||||
import {
|
||||
AuthError,
|
||||
ConnectorError,
|
||||
ProviderRejectedError,
|
||||
RateLimitedHttpClient,
|
||||
type PlatformConnector,
|
||||
} from "@tactical-media-broadcast-hub/connector-sdk";
|
||||
import {
|
||||
DispatchFenceError,
|
||||
ExecutionMetadataError,
|
||||
MASTODON_DEDUPE_WINDOW_MS,
|
||||
preparePostForDispatch,
|
||||
publishingIdempotencyKey,
|
||||
publishingPolicyFor,
|
||||
publishWithPolicy,
|
||||
stripExecutionMetadata,
|
||||
} from "./index.js";
|
||||
|
||||
const KEY = "wp3:ws-1:draft-1:a1:publish";
|
||||
const ORIGIN = "https://provider.example";
|
||||
|
||||
/** Records sends independently of the responses the connector sees. */
|
||||
interface Wire {
|
||||
posts: { key: string | null; body: Record<string, unknown> }[];
|
||||
gets: number;
|
||||
}
|
||||
|
||||
type Reply = "ok" | "scheduled" | "500" | "429" | "401" | "422" | "drop";
|
||||
|
||||
/**
|
||||
* Minimal connector over the real SDK HTTP client: an identity GET, then the
|
||||
* publish POST. The scripted reply for each POST is consumed in order.
|
||||
*/
|
||||
function fakeConnector(replies: Reply[], wire: Wire): PlatformConnector {
|
||||
const client = new RateLimitedHttpClient({
|
||||
requestsPerSecond: 1000,
|
||||
sleep: async () => Promise.resolve(),
|
||||
fetchImpl: (async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
||||
const method = init?.method ?? "GET";
|
||||
if (method === "GET") {
|
||||
wire.gets += 1;
|
||||
return new Response("{}");
|
||||
}
|
||||
const body = JSON.parse(String(init?.body)) as Record<string, unknown>;
|
||||
wire.posts.push({ key: new Headers(init?.headers).get("idempotency-key"), body });
|
||||
const reply = replies.shift() ?? "ok";
|
||||
switch (reply) {
|
||||
case "ok":
|
||||
return Response.json({ id: `r${wire.posts.length}`, created_at: "2026-10-09T12:00:00Z" });
|
||||
case "scheduled":
|
||||
return Response.json({ id: "s9", scheduled_at: "2026-10-09T15:00:00.000Z" });
|
||||
case "500":
|
||||
return new Response("", { status: 500 });
|
||||
case "429":
|
||||
return new Response("", { status: 429, headers: { "retry-after": "12" } });
|
||||
case "401":
|
||||
return new Response("", { status: 401 });
|
||||
case "422":
|
||||
return new Response("", { status: 422 });
|
||||
case "drop":
|
||||
throw new TypeError("socket hang up");
|
||||
}
|
||||
void input;
|
||||
throw new Error("unreachable");
|
||||
}) as typeof fetch,
|
||||
});
|
||||
|
||||
return {
|
||||
manifest: () => {
|
||||
throw new Error("not needed");
|
||||
},
|
||||
authorize: async () => {
|
||||
throw new Error("not needed");
|
||||
},
|
||||
getIdentity: async () => {
|
||||
throw new Error("not needed");
|
||||
},
|
||||
async publish(connection: ConnectorConnection, post: NormalizedPost): Promise<Publication> {
|
||||
if (post.text === "too-long") throw new ConnectorError("text over limit", connection.provider);
|
||||
await client.request(connection.provider, `${ORIGIN}/me`);
|
||||
const key = post.providerMetadata["idempotencyKey"];
|
||||
const response = await client.request(connection.provider, `${ORIGIN}/statuses`, {
|
||||
method: "POST",
|
||||
headers: typeof key === "string" ? { "idempotency-key": key } : {},
|
||||
body: JSON.stringify({ status: post.text, scheduled_at: post.scheduledAt?.toISOString() }),
|
||||
});
|
||||
if (response.status === 401) throw new AuthError("revoked", connection.provider, false);
|
||||
if (!response.ok) throw new ProviderRejectedError("refused", connection.provider, response.status);
|
||||
const data = (await response.json()) as { id: string; created_at?: string; scheduled_at?: string };
|
||||
const scheduled = data.scheduled_at !== undefined;
|
||||
return {
|
||||
id: "local-placeholder",
|
||||
workspaceId: connection.workspaceId,
|
||||
draftId: "direct",
|
||||
connectionId: connection.id,
|
||||
remoteId: scheduled ? `scheduled:${data.id}` : data.id,
|
||||
publishedAt: new Date(data.scheduled_at ?? data.created_at ?? 0),
|
||||
providerMetadata: scheduled
|
||||
? { outcome: "provider_scheduled", scheduledAt: data.scheduled_at }
|
||||
: { outcome: "published" },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function connectionFor(provider: string): ConnectorConnection {
|
||||
return {
|
||||
id: `conn-${provider}`,
|
||||
workspaceId: "ws-1",
|
||||
provider,
|
||||
channelId: "c",
|
||||
credentialId: { kind: "credential-ref", id: "cred" },
|
||||
fields: {},
|
||||
createdAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
const post = (text = "hello", extra: Partial<NormalizedPost> = {}): NormalizedPost => ({
|
||||
text,
|
||||
mediaAssetIds: [],
|
||||
visibility: "public",
|
||||
providerMetadata: { connectionId: "conn-x", draftId: "user-draft", idempotencyKey: "user-key" },
|
||||
...extra,
|
||||
});
|
||||
|
||||
function setup(provider: string, replies: Reply[]): { wire: Wire; connector: PlatformConnector; connection: ConnectorConnection } {
|
||||
const wire: Wire = { posts: [], gets: 0 };
|
||||
return { wire, connector: fakeConnector(replies, wire), connection: connectionFor(provider) };
|
||||
}
|
||||
|
||||
describe("idempotency key + dispatch preparation", () => {
|
||||
it("formats the per-attempt key and validates its parts", () => {
|
||||
expect(publishingIdempotencyKey({ workspaceId: "ws-1", draftId: "draft-1", attemptNo: 1 })).toBe(KEY);
|
||||
expect(() => publishingIdempotencyKey({ workspaceId: "ws:1", draftId: "d", attemptNo: 1 })).toThrow(RangeError);
|
||||
expect(() => publishingIdempotencyKey({ workspaceId: "ws", draftId: "d", attemptNo: 0 })).toThrow(RangeError);
|
||||
});
|
||||
|
||||
it("injects the key, drops author execution keys and strips scheduledAt", () => {
|
||||
const original = post("x", { scheduledAt: new Date("2026-12-01T00:00:00Z") });
|
||||
const prepared = preparePostForDispatch(original, { idempotencyKey: KEY });
|
||||
expect(prepared.providerMetadata).toEqual({ connectionId: "conn-x", idempotencyKey: KEY });
|
||||
expect(prepared.scheduledAt).toBeUndefined();
|
||||
// Input untouched.
|
||||
expect(original.providerMetadata["draftId"]).toBe("user-draft");
|
||||
expect(original.scheduledAt).toBeDefined();
|
||||
expect(preparePostForDispatch(original, { idempotencyKey: KEY, allowProviderScheduling: true }).scheduledAt).toEqual(
|
||||
original.scheduledAt,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses author-supplied remote media ids", () => {
|
||||
expect(() => preparePostForDispatch(post("x", { providerMetadata: { mediaIds: ["m1"] } }), { idempotencyKey: KEY })).toThrow(
|
||||
ExecutionMetadataError,
|
||||
);
|
||||
});
|
||||
|
||||
it("stripExecutionMetadata removes only runtime-owned keys (approval hash input)", () => {
|
||||
expect(stripExecutionMetadata(post()).providerMetadata).toEqual({ connectionId: "conn-x" });
|
||||
});
|
||||
|
||||
it("declares token dedupe for Mastodon and none for Telegram/unknown providers", () => {
|
||||
expect(publishingPolicyFor("mastodon")).toEqual({ idempotency: "token", dedupeWindowMs: MASTODON_DEDUPE_WINDOW_MS });
|
||||
expect(publishingPolicyFor("telegram")).toEqual({ idempotency: "none" });
|
||||
expect(publishingPolicyFor("bluesky")).toEqual({ idempotency: "none" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("publishWithPolicy outcomes", () => {
|
||||
it("published: receipt carries the injected key, never the local id/draftId", async () => {
|
||||
const { wire, connector, connection } = setup("telegram", ["ok"]);
|
||||
const outcome = await publishWithPolicy(connector, connection, post(), { idempotencyKey: KEY });
|
||||
expect(wire.posts).toHaveLength(1);
|
||||
expect(wire.posts[0]?.key).toBe(KEY);
|
||||
expect(wire.posts[0]?.body["scheduled_at"]).toBeUndefined();
|
||||
expect(outcome).toMatchObject({ kind: "published", receipt: { remoteId: "r1", idempotencyKey: KEY, connectionId: "conn-telegram" } });
|
||||
expect(outcome.kind === "published" && "id" in outcome.receipt).toBe(false);
|
||||
expect(outcome.kind === "published" && "draftId" in outcome.receipt).toBe(false);
|
||||
});
|
||||
|
||||
it("provider_scheduled when native scheduling is explicitly allowed", async () => {
|
||||
const { connector, connection } = setup("mastodon", ["scheduled"]);
|
||||
const outcome = await publishWithPolicy(connector, connection, post("x", { scheduledAt: new Date("2026-10-09T15:00:00Z") }), {
|
||||
idempotencyKey: KEY,
|
||||
allowProviderScheduling: true,
|
||||
});
|
||||
expect(outcome).toMatchObject({
|
||||
kind: "provider_scheduled",
|
||||
scheduledRemoteId: "scheduled:s9",
|
||||
scheduledAt: new Date("2026-10-09T15:00:00.000Z"),
|
||||
});
|
||||
});
|
||||
|
||||
it("429 → retryable RATE_LIMITED with Retry-After, one send", async () => {
|
||||
const { wire, connector, connection } = setup("telegram", ["429"]);
|
||||
const outcome = await publishWithPolicy(connector, connection, post(), { idempotencyKey: KEY });
|
||||
expect(wire.posts).toHaveLength(1);
|
||||
expect(outcome).toMatchObject({ kind: "retryable", code: "RATE_LIMITED", retryAfterMs: 12_000, resend: "not_delivered", dispatched: true });
|
||||
});
|
||||
|
||||
it("401 → rejected_permanent needing reauthorization; 422 → PROVIDER_REJECTED", async () => {
|
||||
const auth = setup("telegram", ["401"]);
|
||||
expect(await publishWithPolicy(auth.connector, auth.connection, post(), { idempotencyKey: KEY })).toMatchObject({
|
||||
kind: "rejected_permanent",
|
||||
code: "CREDENTIAL_REJECTED",
|
||||
needsReauthorization: true,
|
||||
});
|
||||
const invalid = setup("telegram", ["422"]);
|
||||
expect(await publishWithPolicy(invalid.connector, invalid.connection, post(), { idempotencyKey: KEY })).toMatchObject({
|
||||
kind: "rejected_permanent",
|
||||
code: "PROVIDER_REJECTED",
|
||||
status: 422,
|
||||
needsReauthorization: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("connector validation before any send → INVALID_POST, not dispatched", async () => {
|
||||
const { wire, connector, connection } = setup("telegram", []);
|
||||
const outcome = await publishWithPolicy(connector, connection, post("too-long"), { idempotencyKey: KEY });
|
||||
expect(outcome).toMatchObject({ kind: "rejected_permanent", code: "INVALID_POST", dispatched: false });
|
||||
expect(wire.posts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("author-supplied media ids → INVALID_POST with zero HTTP", async () => {
|
||||
const { wire, connector, connection } = setup("telegram", []);
|
||||
const outcome = await publishWithPolicy(connector, connection, post("x", { providerMetadata: { mediaIds: ["1"] } }), {
|
||||
idempotencyKey: KEY,
|
||||
});
|
||||
expect(outcome).toMatchObject({ kind: "rejected_permanent", code: "INVALID_POST" });
|
||||
expect(wire.gets + wire.posts.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ambiguous sends", () => {
|
||||
it("no-token provider: 5xx and dropped responses are unknown, never retryable", async () => {
|
||||
for (const reply of ["500", "drop"] as const) {
|
||||
const { wire, connector, connection } = setup("telegram", [reply]);
|
||||
const outcome = await publishWithPolicy(connector, connection, post(), { idempotencyKey: KEY });
|
||||
expect(outcome).toMatchObject({ kind: "unknown", code: "PUBLISH_OUTCOME_UNKNOWN", dispatched: true });
|
||||
expect(wire.posts).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("token provider on a verified origin inside the window: retryable deduplicated", async () => {
|
||||
const { connector, connection } = setup("mastodon", ["500"]);
|
||||
const outcome = await publishWithPolicy(connector, connection, post(), { idempotencyKey: KEY, dedupeVerified: true });
|
||||
expect(outcome).toMatchObject({ kind: "retryable", code: "PUBLISH_AMBIGUOUS_DEDUPED", resend: "deduplicated", status: 500 });
|
||||
});
|
||||
|
||||
it("token provider on an unverified origin is unknown", async () => {
|
||||
const { connector, connection } = setup("mastodon", ["500"]);
|
||||
const outcome = await publishWithPolicy(connector, connection, post(), { idempotencyKey: KEY });
|
||||
expect(outcome.kind).toBe("unknown");
|
||||
});
|
||||
|
||||
it("token provider past the window (measured from first dispatch) is unknown", async () => {
|
||||
const { connector, connection } = setup("mastodon", ["drop"]);
|
||||
const outcome = await publishWithPolicy(connector, connection, post(), {
|
||||
idempotencyKey: KEY,
|
||||
dedupeVerified: true,
|
||||
firstDispatchAt: new Date(Date.now() - MASTODON_DEDUPE_WINDOW_MS - 1000),
|
||||
});
|
||||
expect(outcome.kind).toBe("unknown");
|
||||
});
|
||||
|
||||
it("unsettled prior dispatch without dedupe: unknown with zero HTTP", async () => {
|
||||
const { wire, connector, connection } = setup("telegram", ["ok"]);
|
||||
const outcome = await publishWithPolicy(connector, connection, post(), {
|
||||
idempotencyKey: KEY,
|
||||
unsettledPriorDispatch: true,
|
||||
firstDispatchAt: new Date(),
|
||||
dedupeVerified: true,
|
||||
});
|
||||
expect(outcome).toMatchObject({ kind: "unknown", dispatched: true });
|
||||
expect(wire.gets + wire.posts.length).toBe(0);
|
||||
});
|
||||
|
||||
it("unsettled prior dispatch with verified dedupe: replays the same key", async () => {
|
||||
const { wire, connector, connection } = setup("mastodon", ["ok"]);
|
||||
const outcome = await publishWithPolicy(connector, connection, post(), {
|
||||
idempotencyKey: KEY,
|
||||
unsettledPriorDispatch: true,
|
||||
firstDispatchAt: new Date(Date.now() - 60_000),
|
||||
dedupeVerified: true,
|
||||
});
|
||||
expect(outcome.kind).toBe("published");
|
||||
expect(wire.posts.map((p) => p.key)).toEqual([KEY]);
|
||||
});
|
||||
|
||||
it("a definite refusal of a replay does not claim the earlier send was not delivered", async () => {
|
||||
const { connector, connection } = setup("mastodon", ["429"]);
|
||||
const outcome = await publishWithPolicy(connector, connection, post(), {
|
||||
idempotencyKey: KEY,
|
||||
unsettledPriorDispatch: true,
|
||||
firstDispatchAt: new Date(),
|
||||
dedupeVerified: true,
|
||||
});
|
||||
expect(outcome).toMatchObject({ kind: "retryable", code: "RATE_LIMITED", resend: "deduplicated" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("dispatch fence", () => {
|
||||
it("a failing fence blocks the mutating send; reads are not fenced", async () => {
|
||||
const { wire, connector, connection } = setup("telegram", ["ok"]);
|
||||
const error = await publishWithPolicy(connector, connection, post(), {
|
||||
idempotencyKey: KEY,
|
||||
beforeMutation: () => {
|
||||
throw new Error("lease lost");
|
||||
},
|
||||
}).catch((e: unknown) => e);
|
||||
expect(error).toBeInstanceOf(DispatchFenceError);
|
||||
expect(error).toMatchObject({ dispatched: false });
|
||||
expect(wire.gets).toBe(1);
|
||||
expect(wire.posts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("concurrent executions keep their own fences (AsyncLocalStorage isolation)", async () => {
|
||||
const blocked = setup("telegram", ["ok"]);
|
||||
const allowed = setup("telegram", ["ok"]);
|
||||
const seen: string[] = [];
|
||||
const [a, b] = await Promise.allSettled([
|
||||
publishWithPolicy(blocked.connector, blocked.connection, post(), {
|
||||
idempotencyKey: "wp3:ws-1:a:a1:publish",
|
||||
beforeMutation: async () => {
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
seen.push("blocked");
|
||||
throw new Error("revoked");
|
||||
},
|
||||
}),
|
||||
publishWithPolicy(allowed.connector, allowed.connection, post(), {
|
||||
idempotencyKey: "wp3:ws-1:b:a1:publish",
|
||||
beforeMutation: () => void seen.push("allowed"),
|
||||
}),
|
||||
]);
|
||||
expect(a.status).toBe("rejected");
|
||||
expect(b).toMatchObject({ status: "fulfilled", value: { kind: "published" } });
|
||||
expect(blocked.wire.posts).toHaveLength(0);
|
||||
expect(allowed.wire.posts.map((p) => p.key)).toEqual(["wp3:ws-1:b:a1:publish"]);
|
||||
expect(seen.sort()).toEqual(["allowed", "blocked"]);
|
||||
});
|
||||
|
||||
it("calls outside a runtime execution are unaffected by the hook", async () => {
|
||||
const { wire, connector, connection } = setup("telegram", ["ok"]);
|
||||
await publishWithPolicy(connector, connection, post(), {
|
||||
idempotencyKey: KEY,
|
||||
beforeMutation: () => {
|
||||
throw new Error("only inside");
|
||||
},
|
||||
}).catch(() => undefined);
|
||||
const direct = await connector.publish?.(connection, post());
|
||||
expect(direct?.remoteId).toBe("r1");
|
||||
expect(wire.posts).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue