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,27 @@
{
"name": "@tactical-media-broadcast-hub/connector-sdk",
"private": true,
"version": "0.1.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": "./dist/index.js",
"./testing": "./dist/testing/kits.js",
"./rate-policy": "./dist/rate-policy.js",
"./testing/fixtures": "./dist/testing/fixtures.js"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"lint": "eslint src",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run"
},
"dependencies": {
"@tactical-media-broadcast-hub/core": "workspace:*"
},
"devDependencies": {
"typescript": "^5.5.4",
"vitest": "^2.0.5"
}
}

View file

@ -0,0 +1,8 @@
const g1: typeof fetch = globalThis.fetch;
const g3: FormData | undefined = undefined;
const g4: Blob | undefined = undefined;
const g5: typeof process | undefined = undefined;
const g6: Buffer | undefined = undefined;
const g7: RequestInit | undefined = undefined;
const g8: Response | undefined = undefined;
export const probe = { g1, g3, g4, g5, g6, g7, g8 };

View file

@ -0,0 +1,36 @@
import type { ConnectorConnection } from "@tactical-media-broadcast-hub/core";
/**
* Opaque credential access — connectors never handle ciphertext or raw vault
* material. The vault (WP2) implements this port; the in-memory version here
* serves development and contract tests.
*/
export interface CredentialAccessor {
getAccessToken(connection: ConnectorConnection): Promise<string>;
getSecret(connection: ConnectorConnection, key: string): Promise<string>;
put(connection: ConnectorConnection, key: string, value: string): Promise<void>;
}
export class InMemoryCredentials implements CredentialAccessor {
private readonly store = new Map<string, string>();
private key(connection: ConnectorConnection, name: string): string {
return `${connection.id}:${name}`;
}
async getAccessToken(connection: ConnectorConnection): Promise<string> {
const token = this.store.get(this.key(connection, "access_token"));
if (token === undefined) throw new Error(`no access token for connection ${connection.id}`);
return token;
}
async getSecret(connection: ConnectorConnection, key: string): Promise<string> {
const secret = this.store.get(this.key(connection, key));
if (secret === undefined) throw new Error(`no secret "${key}" for connection ${connection.id}`);
return secret;
}
async put(connection: ConnectorConnection, key: string, value: string): Promise<void> {
this.store.set(this.key(connection, key), value);
}
}

View file

@ -0,0 +1,115 @@
/**
* What a failed provider call proves about the side effect:
* - "rejected": the provider answered and definitively did not perform it.
* - "unknown": the request may have been applied (5xx/transport loss on a
* non-idempotent request) — never resend without a provider dedupe key.
*/
export type DeliveryCertainty = "rejected" | "unknown";
export class ConnectorError extends Error {
constructor(
message: string,
readonly provider: string,
override readonly cause?: unknown,
) {
super(message);
this.name = "ConnectorError";
}
}
export class UnknownProviderError extends ConnectorError {
constructor(providerId: string) {
super(`no connector registered for provider "${providerId}"`, providerId);
this.name = "UnknownProviderError";
}
}
export class AuthError extends ConnectorError {
constructor(message: string, provider: string, readonly retryable: boolean) {
super(message, provider);
this.name = "AuthError";
}
}
export class RateLimitError extends ConnectorError {
/** A 429 is a refusal before processing, so nothing was delivered. */
readonly delivery: DeliveryCertainty = "rejected";
constructor(
provider: string,
readonly retryAfterMs?: number,
readonly status = 429,
) {
super(`rate limited by ${provider}`, provider);
this.name = "RateLimitError";
}
}
/** The provider answered with a non-2xx status that is a definite refusal (4xx). */
export class ProviderRejectedError extends ConnectorError {
readonly delivery: DeliveryCertainty = "rejected";
constructor(
message: string,
provider: string,
readonly status: number,
readonly retryAfterMs?: number,
) {
// cause keeps the `{ status }` shape connectors already inspect.
super(message, provider, { status });
this.name = "ProviderRejectedError";
}
}
/**
* 5xx answer the SDK client did not retry (or stopped retrying). For
* non-idempotent requests delivery is "unknown": the server may have applied
* the write before failing.
*/
export class ProviderServerError extends ConnectorError {
constructor(
provider: string,
readonly status: number,
readonly method: string,
readonly delivery: DeliveryCertainty,
readonly retryAfterMs?: number,
) {
super(`${provider}: HTTP ${status} on ${method}`, provider, { status });
this.name = "ProviderServerError";
}
}
/** fetch itself failed (socket loss, DNS, abort) on a non-idempotent request. */
export class ProviderTransportError extends ConnectorError {
readonly delivery: DeliveryCertainty = "unknown";
constructor(
provider: string,
readonly method: string,
cause: unknown,
) {
super(`${provider}: transport failure on ${method}`, provider, cause);
this.name = "ProviderTransportError";
}
}
/** Delivery certainty carried by an SDK error, if the error type records one. */
export function deliveryCertaintyOf(error: unknown): DeliveryCertainty | undefined {
if (error instanceof AuthError) return "rejected";
if (
error instanceof RateLimitError ||
error instanceof ProviderRejectedError ||
error instanceof ProviderServerError ||
error instanceof ProviderTransportError
) {
return error.delivery;
}
return undefined;
}
export class CapabilityNotSupportedError extends ConnectorError {
constructor(provider: string, capability: string) {
super(`${provider} does not support "${capability}"`, provider);
this.name = "CapabilityNotSupportedError";
}
}

View file

@ -0,0 +1,170 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { deliveryCertaintyOf, ProviderServerError, ProviderTransportError, RateLimitError } from "./errors.js";
import { RateLimitedHttpClient, setHttpRequestHookProvider, type HttpRequestEvent } from "./http.js";
const URL_ = "https://api.example.test/v1/things";
function scripted(responses: (() => Response)[]): { fetchImpl: typeof fetch; calls: RequestInit[] } {
const calls: RequestInit[] = [];
const fetchImpl = (async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
calls.push(init ?? {});
const next = responses[Math.min(calls.length - 1, responses.length - 1)];
if (next === undefined) throw new Error("no scripted response");
return next();
}) as typeof fetch;
return { fetchImpl, calls };
}
function client(fetchImpl: typeof fetch): RateLimitedHttpClient {
return new RateLimitedHttpClient({ fetchImpl, requestsPerSecond: 1000, sleep: async () => Promise.resolve() });
}
afterEach(() => setHttpRequestHookProvider(undefined));
describe("http client retry classification", () => {
it("retries idempotent GET on 503 and 429", async () => {
const { fetchImpl, calls } = scripted([
() => new Response("", { status: 503 }),
() => new Response("", { status: 429 }),
() => new Response("ok", { status: 200 }),
]);
const response = await client(fetchImpl).request("p", URL_);
expect(response.status).toBe(200);
expect(calls).toHaveLength(3);
});
it("retries PUT and DELETE", async () => {
for (const method of ["PUT", "DELETE"]) {
const { fetchImpl, calls } = scripted([() => new Response("", { status: 502 }), () => new Response(null, { status: 204 })]);
await client(fetchImpl).request("p", URL_, { method });
expect(calls).toHaveLength(2);
}
});
it("never resends a POST on 429: one call, RateLimitError with header Retry-After", async () => {
const { fetchImpl, calls } = scripted([() => new Response("", { status: 429, headers: { "retry-after": "7" } })]);
const error = await client(fetchImpl).request("p", URL_, { method: "POST" }).catch((e: unknown) => e);
expect(calls).toHaveLength(1);
expect(error).toBeInstanceOf(RateLimitError);
expect(error).toMatchObject({ retryAfterMs: 7000, delivery: "rejected", status: 429 });
});
it("reads retry_after from a JSON 429 body when no header is present", async () => {
const { fetchImpl } = scripted([
() => new Response(JSON.stringify({ ok: false, error_code: 429, parameters: { retry_after: 3 } }), { status: 429 }),
]);
await expect(client(fetchImpl).request("p", URL_, { method: "POST" })).rejects.toMatchObject({ retryAfterMs: 3000 });
});
it("surfaces POST 5xx as ProviderServerError with unknown delivery, without resending", async () => {
const { fetchImpl, calls } = scripted([() => new Response("boom", { status: 500 })]);
const error = await client(fetchImpl).request("p", URL_, { method: "POST" }).catch((e: unknown) => e);
expect(calls).toHaveLength(1);
expect(error).toBeInstanceOf(ProviderServerError);
expect(error).not.toBeInstanceOf(RateLimitError);
expect(error).toMatchObject({ status: 500, method: "POST", delivery: "unknown" });
expect(deliveryCertaintyOf(error)).toBe("unknown");
});
it("wraps POST transport loss as ProviderTransportError (unknown delivery)", async () => {
let calls = 0;
const fetchImpl = (async () => {
calls += 1;
throw new TypeError("socket hang up");
}) as typeof fetch;
const error = await client(fetchImpl).request("p", URL_, { method: "POST" }).catch((e: unknown) => e);
expect(calls).toBe(1);
expect(error).toBeInstanceOf(ProviderTransportError);
expect(deliveryCertaintyOf(error)).toBe("unknown");
});
it("returns 4xx responses to the caller untouched", async () => {
const { fetchImpl } = scripted([() => new Response("{}", { status: 422 })]);
const response = await client(fetchImpl).request("p", URL_, { method: "POST" });
expect(response.status).toBe(422);
});
it("opts a read-only POST into retries and a mutation out of them", async () => {
const retried = scripted([() => new Response("", { status: 503 }), () => new Response("ok")]);
await client(retried.fetchImpl).request("p", URL_, { method: "POST" }, { retry: true });
expect(retried.calls).toHaveLength(2);
const once = scripted([() => new Response("", { status: 503 })]);
await expect(client(once.fetchImpl).request("p", URL_, { method: "PUT" }, { retry: false })).rejects.toBeInstanceOf(
ProviderServerError,
);
expect(once.calls).toHaveLength(1);
});
it("exhausted GET 5xx is a ProviderServerError, not a RateLimitError", async () => {
const { fetchImpl, calls } = scripted([() => new Response("", { status: 503 })]);
const error = await new RateLimitedHttpClient({ fetchImpl, maxRetries: 2, requestsPerSecond: 1000, sleep: async () => Promise.resolve() })
.request("p", URL_)
.catch((e: unknown) => e);
expect(calls).toHaveLength(3);
expect(error).toBeInstanceOf(ProviderServerError);
expect(error).toMatchObject({ delivery: "rejected" });
});
it("exhausted GET 429 stays a RateLimitError carrying Retry-After", async () => {
const { fetchImpl } = scripted([() => new Response("", { status: 429, headers: { "retry-after": "10" } })]);
await expect(client(fetchImpl).request("p", URL_)).rejects.toMatchObject({ name: "RateLimitError", retryAfterMs: 10_000 });
});
});
describe("http request hook", () => {
it("runs beforeRequest per actual send, without the URL path", async () => {
const events: HttpRequestEvent[] = [];
setHttpRequestHookProvider(() => ({ beforeRequest: (event) => void events.push(event) }));
const { fetchImpl } = scripted([() => new Response("", { status: 503 }), () => new Response("ok")]);
await client(fetchImpl).request("p", "https://api.example.test/bot123:secret/getMe");
expect(events.map((e) => e.attempt)).toEqual([0, 1]);
expect(events[0]).toEqual({ provider: "p", method: "GET", origin: "https://api.example.test", attempt: 0, mutating: false });
expect(JSON.stringify(events)).not.toContain("secret");
});
it("a throwing beforeRequest blocks the send entirely", async () => {
setHttpRequestHookProvider(() => ({
beforeRequest: () => {
throw new Error("fence lost");
},
}));
const { fetchImpl, calls } = scripted([() => new Response("ok")]);
await expect(client(fetchImpl).request("p", URL_, { method: "POST" })).rejects.toThrow("fence lost");
expect(calls).toHaveLength(0);
});
});
describe("around-send dispatch serialization", () => {
it("holds the gate across the actual response, after the rate limiter", async () => {
const order: string[] = [];
const { fetchImpl } = scripted([() => { order.push("send"); return new Response("ok"); }]);
setHttpRequestHookProvider(() => ({ aroundMutation: async (_event, send) => { order.push("lock"); const response = await send(); order.push("unlock"); return response; } }));
await client(fetchImpl).request("p", URL_, { method: "POST" }, { retry: false });
expect(order).toEqual(["lock", "send", "unlock"]);
});
it("preserves gate errors and never classifies them as transport delivery", async () => {
const failure = new Error("gate refused"); const { fetchImpl, calls } = scripted([() => new Response("ok")]);
setHttpRequestHookProvider(() => ({ aroundMutation: async () => { throw failure; } }));
await expect(client(fetchImpl).request("p", URL_, { method: "POST" }, { retry: false })).rejects.toBe(failure);
expect(calls).toHaveLength(0);
});
});
describe("rate wait precedes the dispatch gate", () => {
it("does not acquire the mutation gate or stamp a send until the limiter releases", async () => {
vi.useFakeTimers();
try {
const { fetchImpl, calls } = scripted([() => new Response("ok")]);
const gate = vi.fn(async (_event: HttpRequestEvent, send: () => Promise<Response>) => send());
setHttpRequestHookProvider(() => ({ aroundMutation: gate }));
const http = new RateLimitedHttpClient({ fetchImpl, requestsPerSecond: 1 });
await http.request("p", URL_);
const pending = http.request("p", URL_, { method: "POST" }, { retry: false });
await vi.advanceTimersByTimeAsync(999);
expect(gate).not.toHaveBeenCalled(); expect(calls).toHaveLength(1);
await vi.advanceTimersByTimeAsync(1); await pending;
expect(gate).toHaveBeenCalledTimes(1); expect(calls).toHaveLength(2);
} finally { vi.useRealTimers(); }
});
});

View file

@ -0,0 +1,217 @@
/**
* Rate-limited HTTP client for connector packages: per-host token bucket,
* exponential backoff with jitter on 429/5xx, fetch-based.
*
* Retry classification: only idempotent methods (GET/HEAD/OPTIONS/PUT/DELETE)
* are retried automatically. A non-idempotent request (POST/PATCH) that gets
* 429/5xx or loses its transport is never resent here — the caller receives a
* typed error recording whether delivery was definitely refused or unknown.
* Read-only POST endpoints can opt back in with `{ retry: true }`; publishing
* mutations can opt out entirely with `{ retry: false }`.
*/
import {
ProviderRejectedError,
ProviderServerError,
ProviderTransportError,
RateLimitError,
} from "./errors.js";
interface Bucket {
tokens: number;
lastRefillMs: number;
}
export interface HttpClientOptions {
/** Requests per second allowed per host (default 1). */
requestsPerSecond?: number;
/** Max backoff attempt count (default 4). */
maxRetries?: number;
fetchImpl?: typeof fetch;
sleep?: (ms: number) => Promise<void>;
}
export interface HttpRequestOptions {
/**
* Auto-retry on 429/5xx. Default: true for idempotent methods, false
* otherwise. `true` on a POST asserts the endpoint is side-effect free.
*/
retry?: boolean;
}
/** Nonsecret request description handed to hooks — never the full URL. */
export interface HttpRequestEvent {
provider: string;
method: string;
origin: string;
/** 0 for the first send, then one per automatic retry. */
attempt: number;
/** Whether this request may have side effects on the provider. */
mutating: boolean;
}
export interface HttpResponseEvent extends HttpRequestEvent {
status: number;
}
/**
* Per-execution hook, resolved for every request. `beforeRequest` runs after
* the rate-limiter wait and immediately before each actual send; throwing
* from it blocks the send (zero HTTP requests leave the process).
*/
export interface HttpRequestHook {
aroundMutation?(event: HttpRequestEvent, send: () => Promise<Response>): Promise<Response>;
beforeRequest?(event: HttpRequestEvent): void | Promise<void>;
afterResponse?(event: HttpResponseEvent): void;
afterTransportError?(event: HttpRequestEvent): void;
}
let hookProvider: (() => HttpRequestHook | undefined) | undefined;
/**
* Install the resolver for the current execution's hook (e.g. an
* AsyncLocalStorage lookup in the publishing runtime). The resolver is
* consulted per request, so concurrent executions never share a hook.
* Pass undefined to uninstall.
*/
export function setHttpRequestHookProvider(provider: (() => HttpRequestHook | undefined) | undefined): void {
hookProvider = provider;
}
const IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "OPTIONS", "PUT", "DELETE"]);
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
export function isIdempotentMethod(method: string): boolean {
return IDEMPOTENT_METHODS.has(method.toUpperCase());
}
const defaultSleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
/** Retry-After header: delta-seconds or HTTP-date → ms. */
function headerRetryAfterMs(response: Response): number | undefined {
const raw = response.headers.get("retry-after");
if (raw === null || raw.trim().length === 0) return undefined;
const seconds = Number(raw);
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
const at = Date.parse(raw);
return Number.isFinite(at) ? Math.max(0, at - Date.now()) : undefined;
}
/** `retry_after` (seconds) in a JSON error body, top level or under `parameters`. */
async function bodyRetryAfterMs(response: Response): Promise<number | undefined> {
try {
const body = (await response.json()) as {
retry_after?: unknown;
parameters?: { retry_after?: unknown } | null;
} | null;
const value = body?.parameters?.retry_after ?? body?.retry_after;
return typeof value === "number" && Number.isFinite(value) ? Math.max(0, value * 1000) : undefined;
} catch {
return undefined;
}
}
export class RateLimitedHttpClient {
private readonly buckets = new Map<string, Bucket>();
private readonly rps: number;
private readonly maxRetries: number;
private readonly fetchImpl: typeof fetch;
private readonly sleep: (ms: number) => Promise<void>;
constructor(opts: HttpClientOptions = {}) {
this.rps = opts.requestsPerSecond ?? 1;
this.maxRetries = opts.maxRetries ?? 4;
this.fetchImpl = opts.fetchImpl ?? fetch;
this.sleep = opts.sleep ?? defaultSleep;
}
private async takeToken(host: string): Promise<void> {
for (;;) {
const now = Date.now();
const bucket = this.buckets.get(host) ?? { tokens: 1, lastRefillMs: now };
const elapsed = (now - bucket.lastRefillMs) / 1000;
bucket.tokens = Math.min(1, bucket.tokens + elapsed * this.rps);
bucket.lastRefillMs = now;
if (bucket.tokens >= 1) {
bucket.tokens -= 1;
this.buckets.set(host, bucket);
return;
}
this.buckets.set(host, bucket);
const waitMs = Math.ceil(((1 - bucket.tokens) / this.rps) * 1000);
await this.sleep(waitMs);
}
}
/**
* Never resolves with a 429/5xx: those are retried (when allowed) and
* otherwise thrown as RateLimitError / ProviderServerError.
*/
async request(
provider: string,
url: string,
init: RequestInit = {},
options: HttpRequestOptions = {},
): Promise<Response> {
const parsed = new URL(url);
const method = (init.method ?? "GET").toUpperCase();
const retry = options.retry ?? isIdempotentMethod(method);
// A POST opted into retries is asserted side-effect free.
const mutating = !SAFE_METHODS.has(method) && !(options.retry === true && !isIdempotentMethod(method));
let attempt = 0;
for (;;) {
await this.takeToken(parsed.host);
const hook = hookProvider?.();
const event: HttpRequestEvent = { provider, method, origin: parsed.origin, attempt, mutating };
await hook?.beforeRequest?.(event);
const send = async (): Promise<Response> => {
try { return await this.fetchImpl(url, init); }
catch (cause) {
hook?.afterTransportError?.(event);
if (retry) throw cause;
throw new ProviderTransportError(provider, method, cause);
}
};
const response = event.mutating && hook?.aroundMutation !== undefined
? await hook.aroundMutation(event, send)
: await send();
hook?.afterResponse?.({ ...event, status: response.status });
const failed = response.status === 429 || response.status >= 500;
if (!failed) {
return response;
}
const headerAfter = headerRetryAfterMs(response);
if (!retry || attempt >= this.maxRetries) {
if (response.status === 429) {
throw new RateLimitError(provider, headerAfter ?? (await bodyRetryAfterMs(response)));
}
const delivery = SAFE_METHODS.has(method) ? "rejected" : "unknown";
throw new ProviderServerError(provider, response.status, method, delivery, headerAfter);
}
const backoffMs = headerAfter ?? Math.min(30_000, 2 ** attempt * 500 + Math.random() * 250);
await this.sleep(backoffMs);
attempt += 1;
}
}
async json<T>(
provider: string,
url: string,
init: RequestInit = {},
options: HttpRequestOptions = {},
): Promise<T> {
const response = await this.request(
provider,
url,
{ ...init, headers: { accept: "application/json", ...init.headers } },
options,
);
if (!response.ok) {
throw new ProviderRejectedError(
`${provider}: HTTP ${response.status} for ${url}`,
provider,
response.status,
);
}
return (await response.json()) as T;
}
}

View file

@ -0,0 +1,8 @@
export * from "./errors.js";
export * from "./credentials.js";
export * from "./http.js";
export * from "./manifest.js";
export * from "./page.js";
export * from "./rate-policy.js";
export * from "./registry.js";
export * from "./types.js";

View file

@ -0,0 +1,109 @@
import type { PlatformConnector } from "./types.js";
import type { ConnectorCapabilities } from "@tactical-media-broadcast-hub/core";
export interface ConnectionField {
name: string;
label: string;
required: boolean;
/** Field is secret — UI masks it, vault stores it. */
secret: boolean;
/** Optional validation pattern (regex source). */
pattern?: string;
placeholder?: string;
}
export interface ConnectorManifest {
/** Unique provider id, e.g. "mock-social". Dots/kebab-case, no spaces. */
id: string;
displayName: string;
capabilities: ConnectorCapabilities;
/** Fields the UI renders when adding a connection (e.g. instanceUrl). */
connectionFields: ConnectionField[];
/** OAuth flavor for the generic authorization flow. */
oauth?: {
kind: "oauth2-redirect" | "token" | "per-instance-oauth2";
authorizeUrlTemplate?: string;
scopes?: string[];
pkce?: boolean;
/** False when this deployment cannot support a redirect connect flow. */
connectEnabled?: boolean;
connectNote?: string;
/** Token providers may require a publishing target besides the token. */
targetField?: string;
};
/**
* Streams-column capability (WP3 §2.2): the connector implements
* `search()` for keyword/hashtag columns. Optional so existing manifests
* compile untouched; boolean when present, and must agree with the method
* actually on the connector (validated in `assertValidManifest`).
*/
search?: boolean;
/**
* Streams-column capability (WP3 §2.2): the connector implements
* `getUserFeed()` for account-list columns. Same optionality/consistency
* rules as `search`.
*/
authorFeed?: boolean;
/** Base route this connector's webhooks are served under (verification is connector-side). */
webhookRoute?: string;
docsUrl?: string;
icon?: string;
}
/** Streams-column capability flags (WP3 §2.2) and the method each gates. */
export const STREAMS_COLUMN_FLAGS = [
{ flag: "search", method: "search" },
{ flag: "authorFeed", method: "getUserFeed" },
] as const satisfies ReadonlyArray<{ flag: "search" | "authorFeed"; method: "search" | "getUserFeed" }>;
/**
* Validate a manifest. When a connector is supplied, streams-column capability
* flags are also cross-checked against the connector surface: a declared flag
* without the method, or a method behind an undeclared flag, is an error —
* mirrors never lie (same rule the contract kits enforce).
*/
export function assertValidManifest(m: ConnectorManifest, connector?: PlatformConnector): void {
if (!/^[a-z0-9][a-z0-9.-]*$/.test(m.id)) {
throw new Error(`manifest.id "${m.id}" must be kebab-case`);
}
if (m.id.length < 2) throw new Error("manifest.id too short");
if (!m.displayName) throw new Error("manifest.displayName required");
if (!m.capabilities) throw new Error("manifest.capabilities required");
for (const field of m.connectionFields) {
if (field.secret && field.name === "password") {
throw new Error("connectionFields must not include a password field — tokens only");
}
}
const caps = Object.entries(m.capabilities).filter(([, v]) => v === true).length;
if (caps === 0) throw new Error("manifest must declare at least one capability");
for (const { flag, method } of STREAMS_COLUMN_FLAGS) {
const value = m[flag];
if (value !== undefined && typeof value !== "boolean") {
throw new Error(`manifest.${flag} must be a boolean when present`);
}
if (connector === undefined) continue;
const implemented = typeof connector[method] === "function";
if (value === true && !implemented) {
throw new Error(`manifest.${flag} declared but connector.${method}() is not implemented`);
}
if (value !== true && implemented) {
throw new Error(
`connector.${method}() implemented but manifest.${flag} is not true — undeclared capability`,
);
}
}
}
/**
* Capability strings for `/feeds` meta: the existing capability strings plus
* the streams-column flags when declared ("search", "authorFeed"), so the UI
* gates keyword/author affordances with zero new endpoints (WP3 §2.2).
*/
export function manifestCapabilityStrings(m: ConnectorManifest): string[] {
const strings = Object.entries(m.capabilities)
.filter(([, v]) => v === true)
.map(([k]) => k);
if (m.search === true) strings.push("search");
if (m.authorFeed === true) strings.push("authorFeed");
return strings;
}

View file

@ -0,0 +1,13 @@
/** Standardized cursor page — every list-shaped connector method returns this. */
export interface Page<T, C = string> {
items: T[];
nextCursor?: C;
}
export function emptyPage<T>(): Page<T> {
return { items: [] };
}
export function pageOf<T>(items: T[], nextCursor?: string): Page<T> {
return nextCursor === undefined ? { items } : { items, nextCursor };
}

View file

@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import { capacitySnapshot, exceedsDocumentedCap, humanizeMs, resolveRatePolicy } from "./rate-policy.js";
describe("rate policy resolution", () => {
it("falls back to documented defaults when nothing overrides", () => {
const policy = resolveRatePolicy("instagram", { dailyPostCap: 25, requestsPerSecond: 0.5 });
expect(policy.dailyPostCap).toBe(25);
expect(policy.requestsPerSecond).toBe(0.5);
});
it("connection override beats documented (news tier)", () => {
const policy = resolveRatePolicy(
"instagram",
{ dailyPostCap: 25 },
{ dailyPostCap: 100, tierNote: "news-publisher advanced access" },
);
expect(policy.dailyPostCap).toBe(100);
expect(policy.tierNote).toBe("news-publisher advanced access");
});
it("env beats documented, override beats env", () => {
process.env.CONNECTOR_RATE_THREADS_DAILY_POSTS = "500";
process.env.CONNECTOR_RATE_THREADS_RPS = "2";
const fromEnv = resolveRatePolicy("threads", { dailyPostCap: 250, requestsPerSecond: 0.5 });
expect(fromEnv.dailyPostCap).toBe(500);
expect(fromEnv.requestsPerSecond).toBe(2);
const fromOverride = resolveRatePolicy("threads", { dailyPostCap: 250 }, { dailyPostCap: 750 });
expect(fromOverride.dailyPostCap).toBe(750);
delete process.env.CONNECTOR_RATE_THREADS_DAILY_POSTS;
delete process.env.CONNECTOR_RATE_THREADS_RPS;
});
it("sanitize rejects zero/negative/garbage", () => {
const policy = resolveRatePolicy("x", { dailyPostCap: -5, requestsPerSecond: 0 }, { commentsPerHour: 0 });
expect(policy.dailyPostCap).toBe(FALLBACK_FOR("dailyPostCap"));
expect(policy.requestsPerSecond).toBe(FALLBACK_FOR("requestsPerSecond"));
});
it("cap-exceedance warning hook", () => {
expect(exceedsDocumentedCap({ dailyPostCap: 25 }, { dailyPostCap: 100 })).toBe(true);
expect(exceedsDocumentedCap({ dailyPostCap: 25 }, { dailyPostCap: 25 })).toBe(false);
expect(exceedsDocumentedCap({ dailyPostCap: 25 })).toBe(false);
});
});
describe("capacity snapshot (rolling window)", () => {
const HOUR = 3600_000;
const now = Date.now();
const ago = (h: number): string => new Date(now - h * HOUR).toISOString();
it("shows remaining under the cap", () => {
const snap = capacitySnapshot(
{ used: 8, cap: 25, recentPublishes: [ago(1), ago(2), ago(3), ago(4), ago(5), ago(6), ago(7), ago(8)] },
resolveRatePolicy("x", { dailyPostCap: 25 }),
);
expect(snap.remaining).toBe(17);
expect(snap.label).toContain("17 of 25");
});
it("at cap: next slot frees when the oldest publish ages out", () => {
const publishes = [ago(3), ...Array.from({ length: 24 }, () => ago(1))];
const snap = capacitySnapshot(
{ used: 25, cap: 25, recentPublishes: publishes },
resolveRatePolicy("x", { dailyPostCap: 25 }),
);
expect(snap.remaining).toBe(0);
expect(snap.nextSlotInMs).toBeDefined();
if (snap.nextSlotInMs !== undefined) {
expect(snap.nextSlotInMs).toBeGreaterThan(20 * HOUR);
expect(snap.nextSlotInMs).toBeLessThan(21 * HOUR);
}
expect(snap.label).toContain("next slot in");
});
it("uncapped connections say so", () => {
const snap = capacitySnapshot({ used: 40, recentPublishes: [] }, { requestsPerSecond: 1 });
expect(snap.remaining).toBeUndefined();
expect(snap.label).toContain("no cap");
});
it("stale publishes (outside 24h) do not count", () => {
const snap = capacitySnapshot(
{ used: 1, cap: 25, recentPublishes: [ago(30)] },
resolveRatePolicy("x", { dailyPostCap: 25 }),
);
expect(snap.remaining).toBe(25);
});
});
describe("humanize", () => {
it("formats durations", () => {
expect(humanizeMs(0)).toBe("now");
expect(humanizeMs(5 * 60000)).toBe("5m");
expect(humanizeMs(90 * 60000)).toBe("1h 30m");
expect(humanizeMs(2 * HOUR_MS)).toBe("2h");
});
});
const HOUR_MS = 3600_000;
function FALLBACK_FOR(key: "requestsPerSecond" | "dailyPostCap"): number {
const base = resolveRatePolicy("fallback-probe", {});
return key === "requestsPerSecond" ? base.requestsPerSecond : (base.dailyPostCap ?? -1);
}

View file

@ -0,0 +1,145 @@
/**
* Rate & capacity policy — settings-driven, never hardcoded.
*
* Three sources, highest precedence first:
* 1. per-connection override (workspace settings / news-tier declarations)
* 2. deployment env: CONNECTOR_RATE_<PROVIDER>_RPS / _DAILY_POSTS / _COMMENTS_HR
* 3. documented provider defaults (what the platform's API documents for
* standard access — e.g. Instagram 25 posts/24h, Threads 250/24h)
*
* IMPORTANT: documented caps are enforced SERVER-SIDE by the provider at the
* standard tier. Raising a value here does not bypass the provider — it
* declares what OUR tier actually permits (news/publisher programs raise
* real limits) so the scheduler can pace within true capacity. Setting a
* value above the provider's enforced cap just means publish attempts will
* be rejected upstream; the scheduler surfaces that, it never fakes it.
*/
export interface RatePolicy {
/** Client politeness toward the provider API (token bucket rate). */
requestsPerSecond: number;
/** Planning budget: max posts per rolling 24h per connection. */
dailyPostCap?: number;
/** Planning budget: max comment/reply calls per hour. */
commentsPerHour?: number;
/** Free-form tier note, e.g. "news-publisher advanced access". */
tierNote?: string;
}
export const FALLBACK_RATE_POLICY: RatePolicy = {
requestsPerSecond: 1,
dailyPostCap: 50,
commentsPerHour: 60,
};
function envNumber(name: string): number | undefined {
const raw = process.env[name];
if (raw === undefined || raw === "") return undefined;
const value = Number(raw);
return Number.isFinite(value) && value > 0 ? value : undefined;
}
function envString(name: string): string | undefined {
const raw = process.env[name];
return raw !== undefined && raw !== "" ? raw : undefined;
}
function sanitize(partial: Partial<RatePolicy>): Partial<RatePolicy> {
const clean: Partial<RatePolicy> = {};
if (partial.requestsPerSecond !== undefined && partial.requestsPerSecond > 0) {
clean.requestsPerSecond = partial.requestsPerSecond;
}
if (partial.dailyPostCap !== undefined && partial.dailyPostCap > 0) clean.dailyPostCap = partial.dailyPostCap;
if (partial.commentsPerHour !== undefined && partial.commentsPerHour > 0) clean.commentsPerHour = partial.commentsPerHour;
if (partial.tierNote !== undefined && partial.tierNote !== "") clean.tierNote = partial.tierNote;
return clean;
}
/**
* Resolve the effective rate policy for a connection.
*
* @param provider manifest id, e.g. "instagram" (used for env var names)
* @param documented provider-documented defaults for standard access
* @param override per-connection override from workspace settings
*/
export function resolveRatePolicy(
provider: string,
documented: Partial<RatePolicy>,
override?: Partial<RatePolicy>,
): RatePolicy {
const envPrefix = `CONNECTOR_RATE_${provider.toUpperCase().replace(/-/g, "_")}`;
const fromEnv: Partial<RatePolicy> = {
requestsPerSecond: envNumber(`${envPrefix}_RPS`),
dailyPostCap: envNumber(`${envPrefix}_DAILY_POSTS`),
commentsPerHour: envNumber(`${envPrefix}_COMMENTS_PER_HOUR`),
tierNote: envString(`${envPrefix}_TIER_NOTE`),
};
const merged: RatePolicy = {
...FALLBACK_RATE_POLICY,
...sanitize(documented),
...sanitize(fromEnv),
...sanitize(override ?? {}),
};
return merged;
}
/** True when the override would exceed the documented standard-access cap (UI warning hook). */
export function exceedsDocumentedCap(documented: Partial<RatePolicy>, override?: Partial<RatePolicy>): boolean {
if (override?.dailyPostCap === undefined || documented.dailyPostCap === undefined) return false;
return override.dailyPostCap > documented.dailyPostCap;
}
export interface CapacityUsage {
/** Posts published via this connection in the current rolling 24h window. */
used: number;
/** Effective cap (resolved policy). Undefined when uncapped. */
cap?: number;
/** ISO timestamps of recent publishes (oldest first) for window math. */
recentPublishes: string[];
}
export interface CapacitySnapshot {
used: number;
cap?: number;
remaining?: number;
/** ms until the next slot frees (oldest publish exits the window). Undefined when full availability. */
nextSlotInMs?: number;
/** Human label, e.g. "17 left today · next slot in 2h 14m" */
label: string;
}
/**
* Rolling-window capacity snapshot for the UI.
* The window is a sliding 24h (provider caps are rolling, not calendar-day),
* so slots free up as old publishes age out.
*/
export function capacitySnapshot(usage: CapacityUsage, policy: RatePolicy): CapacitySnapshot {
const cap = policy.dailyPostCap;
const now = Date.now();
const windowMs = 24 * 60 * 60 * 1000;
const inWindow = usage.recentPublishes
.map((iso) => Date.parse(iso))
.filter((t) => Number.isFinite(t) && t > now - windowMs)
.sort((a, b) => a - b);
const used = usage.recentPublishes.length > 0 ? inWindow.length : usage.used;
if (cap === undefined) {
return { used, label: `${used} today · no cap` };
}
const remaining = Math.max(0, cap - used);
if (remaining > 0) {
return { used, cap, remaining, label: `${remaining} of ${cap} left · rolling 24h` };
}
const oldest = inWindow[0];
const nextSlotInMs = oldest !== undefined ? Math.max(0, oldest + windowMs - now) : undefined;
const human = nextSlotInMs !== undefined ? humanizeMs(nextSlotInMs) : "soon";
return { used, cap, remaining: 0, nextSlotInMs, label: `0 of ${cap} left · next slot in ${human}` };
}
export function humanizeMs(ms: number): string {
if (ms <= 0) return "now";
const minutes = Math.round(ms / 60000);
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
const rem = minutes % 60;
return rem === 0 ? `${hours}h` : `${hours}h ${rem}m`;
}

View file

@ -0,0 +1,41 @@
import type { PlatformConnector } from "./types.js";
import { UnknownProviderError } from "./errors.js";
export type ConnectorFactory = () => PlatformConnector;
/**
* Provider registry. Adding a connector = registering here; the rest of the
* application resolves by provider id and never switches on names.
*/
export class ConnectorRegistry {
private readonly connectors = new Map<string, PlatformConnector>();
register(connector: PlatformConnector): void {
const { id } = connector.manifest();
if (this.connectorsHas(id)) {
throw new Error(`connector "${id}" already registered`);
}
this.connectors.set(id, connector);
}
get(providerId: string): PlatformConnector {
const connector = this.connectors.get(providerId);
if (connector === undefined) throw new UnknownProviderError(providerId);
return connector;
}
has(providerId: string): boolean {
return this.connectors.has(providerId);
}
list(): PlatformConnector[] {
return [...this.connectors.values()];
}
private connectorsHas(id: string): boolean {
return this.connectors.has(id);
}
}
export const CONNECTOR_REGISTRY = Symbol("CONNECTOR_REGISTRY");

View file

@ -0,0 +1,123 @@
import { describe, expect, it } from "vitest";
import type { ConnectorManifest } from "../manifest.js";
import { assertValidManifest, manifestCapabilityStrings, STREAMS_COLUMN_FLAGS } from "../manifest.js";
import {
createNonCapableFixture,
createSearchCapableFixture,
fixtureConnection,
fixtureFeedItem,
} from "./fixtures.js";
import { connectorContractTests } from "./kits.js";
/**
* P0b contract tests: capability gating for streams-column search surfaces.
* A connector may only expose `search()` / `getUserFeed()` when the manifest
* declares `search` / `authorFeed`, and a declared flag requires the method.
*/
describe("streams-column manifest validation", () => {
const base: ConnectorManifest = {
id: "fixture-search",
displayName: "Fixture Search Connector",
capabilities: { publishText: true },
connectionFields: [{ name: "instanceUrl", label: "Instance URL", required: false, secret: false }],
};
it("accepts flags true with methods present", () => {
const { connector } = createSearchCapableFixture();
expect(() => assertValidManifest(connector.manifest(), connector)).not.toThrow();
});
it("accepts manifests without the new flags (existing connectors untouched)", () => {
expect(() => assertValidManifest(base)).not.toThrow();
});
it("rejects non-boolean flags", () => {
for (const bad of [
{ ...base, search: "yes" as unknown as boolean },
{ ...base, authorFeed: 1 as unknown as boolean },
]) {
expect(() => assertValidManifest(bad)).toThrow(/must be a boolean/);
}
});
it("rejects a declared flag without the implementing method", () => {
const { connector } = createNonCapableFixture();
const manifest = { ...connector.manifest(), search: true };
expect(() => assertValidManifest(manifest, connector)).toThrow(/declared but connector\.search\(\)/);
});
it("rejects the method behind an undeclared flag", () => {
const { connector } = createSearchCapableFixture();
const manifest = { ...connector.manifest(), search: false };
expect(() => assertValidManifest(manifest, connector)).toThrow(/undeclared capability/);
});
it("rejects authorFeed declared without getUserFeed", () => {
const { connector } = createNonCapableFixture();
const manifest = { ...connector.manifest(), authorFeed: true };
expect(() => assertValidManifest(manifest, connector)).toThrow(
/declared but connector\.getUserFeed\(\)/,
);
});
it("maps flags to capability strings for /feeds meta", () => {
const capable = manifestCapabilityStrings(createSearchCapableFixture().connector.manifest());
expect(capable).toContain("search");
expect(capable).toContain("authorFeed");
const plain = manifestCapabilityStrings(createNonCapableFixture().connector.manifest());
expect(plain).not.toContain("search");
expect(plain).not.toContain("authorFeed");
});
it("exposes the flag→method table", () => {
expect(STREAMS_COLUMN_FLAGS).toEqual([
{ flag: "search", method: "search" },
{ flag: "authorFeed", method: "getUserFeed" },
]);
});
});
describe("streams-column fixtures", () => {
it("search-capable fixture serves keyword and hashtag queries, keyless and connected", async () => {
const { connector, calls } = createSearchCapableFixture();
const conn = fixtureConnection("fixture-search");
const keyless = await connector.search?.(null, { query: { text: "acampada26" }, limit: 10 });
expect(keyless?.items).toHaveLength(1);
expect(keyless?.items[0]?.text).toBe("search:acampada26");
expect(calls.search[0]).toMatchObject({ connectionWasNull: true, query: "acampada26", kind: undefined });
const tagged = await connector.search?.(conn, { query: { text: "#acampada26", kind: "hashtag" }, limit: 10 });
expect(tagged?.items).toHaveLength(1);
expect(calls.search[1]).toMatchObject({ connectionWasNull: false, query: "#acampada26", kind: "hashtag" });
const author = await connector.getUserFeed?.(null, { account: { handle: "@user@inst.test" }, limit: 5 });
expect(author?.items[0]?.text).toBe("user:@user@inst.test");
expect(calls.getUserFeed[0]).toMatchObject({ connectionWasNull: true, account: "@user@inst.test" });
});
it("non-capable fixture has neither method nor flag — the legacy connector shape", () => {
const { connector } = createNonCapableFixture();
const manifest = connector.manifest();
expect(manifest.search).toBeUndefined();
expect(manifest.authorFeed).toBeUndefined();
expect(connector.search).toBeUndefined();
expect(connector.getUserFeed).toBeUndefined();
});
it("fixture items are well-formed FeedItems", () => {
const item = fixtureFeedItem({ remoteId: "x-9" });
expect(item).toMatchObject({ provider: "fixture-search", remoteId: "x-9", authorHandle: "@fixture" });
expect(item.createdAt).toBeInstanceOf(Date);
});
});
describe("capability gating via the shared contract kit", () => {
connectorContractTests(createSearchCapableFixture().connector, {
connection: fixtureConnection("fixture-search"),
});
connectorContractTests(createNonCapableFixture().connector, {
connection: fixtureConnection("fixture-basic"),
});
});

View file

@ -0,0 +1,136 @@
import type {
ConnectorCapabilities,
ConnectorConnection,
FeedItem,
ProviderId,
} from "@tactical-media-broadcast-hub/core";
import type { PlatformConnector } from "../types.js";
import type { ConnectorManifest } from "../manifest.js";
import { assertValidManifest } from "../manifest.js";
import { pageOf, type Page } from "../page.js";
/**
* Streams-column contract fixtures (WP3 §6 P0b): a search-capable and a
* non-capable fixture connector exercising the optional `search()` /
* `getUserFeed()` surface, capability flags, and the gating rules.
*/
/** Minimal in-memory FeedItem factory for fixtures and connector tests. */
export function fixtureFeedItem(overrides: Partial<FeedItem> = {}): FeedItem {
const provider: ProviderId = overrides.provider ?? "fixture-search";
return {
id: `${provider}:item-1`,
provider,
remoteId: "item-1",
authorHandle: "@fixture",
text: "fixture post about acampada26",
mediaUrls: [],
createdAt: new Date("2024-01-01T00:00:00Z"),
metrics: { replies: 0, reposts: 0, likes: 0 },
...overrides,
};
}
export function fixtureConnection(provider: ProviderId): ConnectorConnection {
return {
id: `conn-${provider}`,
workspaceId: "ws-1",
provider,
channelId: "channel-1",
credentialId: { kind: "credential-ref", id: "cred-1" },
fields: {},
createdAt: new Date(),
};
}
function fixtureManifest(overrides: Partial<ConnectorManifest>): ConnectorManifest {
const capabilities: ConnectorCapabilities = { feed: true };
return {
id: "fixture-search",
displayName: "Fixture Search Connector",
capabilities,
connectionFields: [{ name: "instanceUrl", label: "Instance URL", required: false, secret: false }],
...overrides,
};
}
export interface FixtureCalls {
search: { connectionWasNull: boolean; query: string; kind: "keyword" | "hashtag" | undefined }[];
getUserFeed: { connectionWasNull: boolean; account: string }[];
}
/**
* Search-capable fixture: declares `search: true` + `authorFeed: true` and
* implements both methods. Records whether callers passed a keyless (`null`)
* connection so tests can assert the keyless contract.
*/
export function createSearchCapableFixture(): {
connector: PlatformConnector;
calls: FixtureCalls;
} {
const calls: FixtureCalls = { search: [], getUserFeed: [] };
const connector: PlatformConnector = {
manifest() {
const manifest = fixtureManifest({ search: true, authorFeed: true });
assertValidManifest(manifest, connector);
return manifest;
},
async authorize() {
throw new Error("fixture: authorize not exercised");
},
async getIdentity() {
return { provider: "fixture-search", remoteId: "fixture-1", handle: "@fixture" };
},
async getFeed(_connection, request) {
if (request.kind === "search") {
throw new Error("fixture: use search() for keyword columns");
}
return pageOf([fixtureFeedItem()]);
},
async search(conn, request) {
calls.search.push({
connectionWasNull: conn === null,
query: request.query.text,
kind: request.query.kind,
});
return pageOf([fixtureFeedItem({ text: `search:${request.query.text}` })]);
},
async getUserFeed(conn, request) {
calls.getUserFeed.push({
connectionWasNull: conn === null,
account: request.account.handle ?? request.account.externalId ?? "",
});
return pageOf([fixtureFeedItem({ text: `user:${request.account.handle ?? request.account.externalId}` })]);
},
};
return { connector, calls };
}
/**
* Non-capable fixture: implements neither `search` nor `getUserFeed` and
* declares neither flag. Existing connectors compile untouched — this shape is
* exactly what every pre-columns connector already looks like.
*/
export function createNonCapableFixture(): { connector: PlatformConnector } {
const connector: PlatformConnector = {
manifest() {
const manifest = fixtureManifest({ id: "fixture-basic" });
assertValidManifest(manifest);
return manifest;
},
async authorize() {
throw new Error("fixture: authorize not exercised");
},
async getIdentity() {
return { provider: "fixture-basic", remoteId: "fixture-2", handle: "@basic" };
},
async getFeed() {
return pageOf([fixtureFeedItem({ provider: "fixture-basic", id: "fixture-basic:item-1" })]);
},
};
return { connector };
}
/** Type-level witness: a Page of FeedItem is what both methods return. */
export type FixtureFeedPage = Page<FeedItem>;

View file

@ -0,0 +1,225 @@
/**
* Capability-aware contract test kits. A connector that lies about its
* capabilities FAILS: declared capability without the matching method is an
* error; the kits only assert what the manifest declares.
*/
import { describe, expect, it } from "vitest";
import type { PlatformConnector } from "../types.js";
import { assertValidManifest, manifestCapabilityStrings } from "../manifest.js";
import { ConnectorRegistry } from "../registry.js";
import { UnknownProviderError } from "../errors.js";
export interface ContractHarness {
/** A live-ish connection the connector can operate on (mock state ok). */
connection: import("@tactical-media-broadcast-hub/core").ConnectorConnection;
}
const CAPABILITY_METHOD: Record<string, keyof PlatformConnector> = {
search: "search",
authorFeed: "getUserFeed",
publishText: "publish",
publishImages: "publish",
publishCarousel: "publish",
publishVideo: "uploadVideo",
videoThumbnails: "uploadVideo",
playlists: "getVideos",
feed: "getFeed",
comments: "getComments",
replies: "reply",
amplifyRepost: "amplify",
amplifyQuote: "amplify",
amplifyShare: "amplify",
directMessages: "getMessages",
livestreams: "getLiveStreams",
createLivestream: "createLiveStream",
manageLivestream: "updateLiveStream",
livePlaybackSource: "getStreamPlaybackSources",
liveChatRead: "getLiveChat",
liveChatWrite: "sendLiveChatMessage",
liveChatModeration: "moderateLiveChat",
analytics: "getAnalytics",
webhooks: "subscribeWebhooks",
deletePublication: "deletePublication",
};
export function connectorContractTests(connector: PlatformConnector, harness: ContractHarness): void {
const manifest = connector.manifest();
describe(`connector contract: ${manifest.id}`, () => {
it("has a valid manifest", () => {
assertValidManifest(manifest);
});
it("declares no password field", () => {
for (const field of manifest.connectionFields) {
expect(field.name.toLowerCase()).not.toContain("password");
}
});
it("declared capabilities have implementing methods", () => {
for (const [capability, method] of Object.entries(CAPABILITY_METHOD)) {
if (manifest.capabilities[capability as keyof typeof manifest.capabilities] === true) {
expect(
connector[method],
`capability "${capability}" declared but method "${String(method)}" missing`,
).toBeTypeOf("function");
}
}
});
it("methods behind undeclared capabilities are absent", () => {
const caps = manifest.capabilities;
if (!caps.feed) expect(connector.getFeed).toBeUndefined();
if (!caps.publishText) expect(connector.publish).toBeUndefined();
if (!caps.livestreams) expect(connector.getLiveStreams).toBeUndefined();
});
it("resolves in the registry and unknown providers throw", () => {
const registry = new ConnectorRegistry();
registry.register(connector);
expect(registry.get(manifest.id)).toBe(connector);
expect(() => registry.get("no-such-provider-xyz")).toThrow(UnknownProviderError);
});
it("returns a stable identity", async () => {
const identity = await connector.getIdentity(harness.connection);
expect(identity.provider).toBe(manifest.id);
expect(identity.handle).toBeTruthy();
});
// --- Streams-column capability gating (WP3 §6 P0b) -------------------
it("declares boolean streams-column flags when present", () => {
for (const flag of ["search", "authorFeed"] as const) {
const value = manifest[flag];
if (value !== undefined) expect(value, `manifest.${flag}`).toBeTypeOf("boolean");
}
});
it("declared search capability has an implementing search method", () => {
if (manifest.search === true) {
expect(connector.search, "manifest.search declared but search() missing").toBeTypeOf("function");
}
});
it("declared authorFeed capability has an implementing getUserFeed method", () => {
if (manifest.authorFeed === true) {
expect(connector.getUserFeed, "manifest.authorFeed declared but getUserFeed() missing").toBeTypeOf(
"function",
);
}
});
it("search is only callable when declared: absent otherwise", () => {
if (manifest.search !== true) {
expect(connector.search, "search() present but manifest.search not declared").toBeUndefined();
}
});
it("getUserFeed is only callable when declared: absent otherwise", () => {
if (manifest.authorFeed !== true) {
expect(connector.getUserFeed, "getUserFeed() present but manifest.authorFeed not declared").toBeUndefined();
}
});
it("returns FeedItem pages from search() with a keyless (null) connection", async () => {
if (manifest.search !== true) return;
const page = await connector.search?.(null, { query: { text: "acampada26", kind: "keyword" }, limit: 20 });
expect(page).toBeDefined();
expect(Array.isArray(page?.items)).toBe(true);
for (const item of page?.items ?? []) {
expect(item.provider).toBe(manifest.id);
expect(item.remoteId).toBeTruthy();
expect(item.authorHandle).toBeTruthy();
}
});
it("returns FeedItem pages from getUserFeed() with a keyless (null) connection", async () => {
if (manifest.authorFeed !== true) return;
const page = await connector.getUserFeed?.(null, { account: { handle: "@someone@instance.test" }, limit: 20 });
expect(page).toBeDefined();
expect(Array.isArray(page?.items)).toBe(true);
for (const item of page?.items ?? []) {
expect(item.provider).toBe(manifest.id);
expect(item.remoteId).toBeTruthy();
}
});
it("streams-column flags surface in capability strings", () => {
const strings = manifestCapabilityStrings(manifest);
if (manifest.search === true) expect(strings).toContain("search");
else expect(strings).not.toContain("search");
if (manifest.authorFeed === true) expect(strings).toContain("authorFeed");
else expect(strings).not.toContain("authorFeed");
});
});
}
export function videoConnectorContractTests(connector: PlatformConnector, harness: ContractHarness): void {
const manifest = connector.manifest();
if (manifest.capabilities.publishVideo !== true) return;
describe(`video contract: ${manifest.id}`, () => {
it("uploadVideo returns a remote id", async () => {
const result = await connector.uploadVideo?.(harness.connection, {
videoAssetId: "asset-1",
visibility: "provider_default",
formatIntent: "provider_default",
providerMetadata: {},
});
expect(result?.remoteId).toBeTruthy();
});
it("getVideoStatus reaches a terminal state", async () => {
const upload = await connector.uploadVideo?.(harness.connection, {
videoAssetId: "asset-2",
visibility: "provider_default",
formatIntent: "provider_default",
providerMetadata: {},
});
if (upload === undefined) return;
const status = await connector.getVideoStatus?.(harness.connection, upload.remoteId);
expect(["uploading", "processing", "ready", "failed"]).toContain(status?.state);
});
});
}
export function livestreamConnectorContractTests(
connector: PlatformConnector,
harness: ContractHarness,
): void {
const manifest = connector.manifest();
if (manifest.capabilities.createLivestream !== true) return;
describe(`livestream contract: ${manifest.id}`, () => {
it("createLiveStream produces a stream key reference, never a raw key", async () => {
const stream = await connector.createLiveStream?.(harness.connection, {
channelId: "channel-1",
title: "contract test",
permanent: false,
saveReplay: false,
});
expect(stream?.remoteId).toBeTruthy();
if (stream?.streamKeyRef) {
expect(stream.streamKeyRef.kind).toBe("credential-ref");
expect(JSON.stringify(stream)).not.toMatch(/[a-z0-9]{20,}/i);
}
});
it("playback sources are absolute URLs", async () => {
const stream = await connector.createLiveStream?.(harness.connection, {
channelId: "channel-1",
title: "contract test 2",
permanent: false,
saveReplay: false,
});
if (stream === undefined) return;
const sources = await connector.getStreamPlaybackSources?.(harness.connection, {
remoteId: stream.remoteId,
});
for (const source of sources ?? []) {
expect(() => new URL(source.url)).not.toThrow();
}
});
});
}

View file

@ -0,0 +1,233 @@
import type {
Comment,
ConnectorConnection,
CredentialRef,
DirectMessage,
FeedItem,
LiveChatMessage,
LiveSourceResolution,
LiveStream,
MediaAsset,
NormalizedIdentity,
NormalizedPost,
NormalizedVideoPublication,
NormalizedWebhookEvent,
Page,
Publication,
RemoteMedia,
RemoteVideoChannel,
StreamPlaybackSource,
VideoProcessingStatus,
} from "@tactical-media-broadcast-hub/core";
import type { ConnectorManifest } from "./manifest.js";
export interface AuthorizationRequest {
/** Redirect back to the UI after the provider consent round-trip. */
redirectUri: string;
workspaceId: string;
state: string;
connectionId?: string;
fields?: Record<string, string>;
codeVerifier?: string;
}
export interface AuthorizationRedirect {
authorizationUrl: string;
connection: ConnectorConnection;
}
export interface CodeExchangeRequest extends AuthorizationRequest {
code: string;
}
/**
* Query for streams-column keyword search (WP3 §1.1 `keyword`/`saved_search`).
* The connector adapts it to its provider surface: `keyword` → full-text or
* video search, `hashtag` → the hashtag timeline where that is all the
* provider offers (e.g. Mastodon). Non-tag queries on such providers are
* reported unsupported by the caller, never invented as errors here.
*/
export interface SearchQuery {
text: string;
kind?: "keyword" | "hashtag";
}
/** Request shape for {@link PlatformConnector.search}. */
export interface SearchRequest {
query: SearchQuery;
cursor?: string;
limit: number;
}
/**
* A remote author for author-timeline columns (`account_list`). Exactly one of
* `handle` / `externalId` is usually provided; connectors resolve what their
* provider needs (e.g. `@user@instance.social` → account id).
*/
export interface AccountRef {
handle?: string;
externalId?: string;
}
/** Request shape for {@link PlatformConnector.getUserFeed}. */
export interface GetUserFeedRequest {
account: AccountRef;
cursor?: string;
limit: number;
}
export interface AuthorizationResult {
connection: ConnectorConnection;
identity: NormalizedIdentity;
}
export interface PlatformConnector {
manifest(): ConnectorManifest;
authorize(input: AuthorizationRequest): Promise<AuthorizationResult>;
buildAuthorizationUrl?(input: AuthorizationRequest): Promise<AuthorizationRedirect>;
exchangeCode?(input: CodeExchangeRequest): Promise<AuthorizationResult>;
validateToken?(input: { workspaceId: string; connectionId: string; token: string; fields: Record<string, string> }): Promise<AuthorizationResult>;
refreshCredentials?(connection: ConnectorConnection): Promise<CredentialRef>;
revokeCredentials?(connection: ConnectorConnection): Promise<void>;
getIdentity(connection: ConnectorConnection): Promise<NormalizedIdentity>;
publish?(connection: ConnectorConnection, post: NormalizedPost): Promise<Publication>;
deletePublication?(connection: ConnectorConnection, remoteId: string): Promise<void>;
uploadMedia?(connection: ConnectorConnection, media: MediaAsset): Promise<RemoteMedia>;
uploadVideo?(
connection: ConnectorConnection,
video: NormalizedVideoPublication,
): Promise<{ remoteId: string }>;
getVideoStatus?(
connection: ConnectorConnection,
remoteId: string,
): Promise<VideoProcessingStatus>;
getVideos?(
connection: ConnectorConnection,
request: { cursor?: string; limit: number },
): Promise<Page<{ remoteId: string; title?: string; createdAt?: Date }>>;
getFeed?(
connection: ConnectorConnection,
request: { kind: "home" | "mentions" | "search"; query?: string; cursor?: string; limit: number },
): Promise<Page<FeedItem>>;
/**
* Keyword/hashtag discovery for streams columns (WP3 §2.2). `connection` is
* null for keyless sources (public Bluesky, Mastodon public/RSS, YouTube
* RSS) — implementations must not require credentials, or must refuse null
* with a ConnectorError. Declared by `manifest.search`; when the flag is not
* declared the method must be absent (contract-tested).
*/
search?(
connection: ConnectorConnection | null,
request: SearchRequest,
): Promise<Page<FeedItem>>;
/**
* Author timeline for account-list columns (WP3 §2.2). Same `connection |
* null` keyless contract as {@link PlatformConnector.search}. Declared by
* `manifest.authorFeed`; absent otherwise.
*/
getUserFeed?(
connection: ConnectorConnection | null,
request: GetUserFeedRequest,
): Promise<Page<FeedItem>>;
getComments?(
connection: ConnectorConnection,
request: { onRemoteId: string; cursor?: string; limit: number },
): Promise<Page<Comment>>;
getMessages?(
connection: ConnectorConnection,
request: { conversationRemoteId?: string; cursor?: string; limit: number },
): Promise<Page<DirectMessage>>;
reply?(
connection: ConnectorConnection,
request: { onRemoteId: string; text: string },
): Promise<Publication>;
amplify?(
connection: ConnectorConnection,
request: {
kind: "repost" | "quote" | "share";
targetRemoteId: string;
commentary?: string;
quoteAssetIds?: string[];
},
): Promise<Publication>;
unamplify?(connection: ConnectorConnection, remoteId: string): Promise<void>;
getRemoteVideoChannels?(connection: ConnectorConnection): Promise<RemoteVideoChannel[]>;
createLiveStream?(
connection: ConnectorConnection,
request: { channelId: string; title: string; permanent: boolean; saveReplay: boolean },
): Promise<LiveStream>;
updateLiveStream?(
connection: ConnectorConnection,
request: { remoteId: string; title?: string },
): Promise<LiveStream>;
getLiveStreams?(
connection: ConnectorConnection,
request: { cursor?: string; limit: number },
): Promise<Page<LiveStream>>;
getStreamPlaybackSources?(
connection: ConnectorConnection,
request: { remoteId: string },
): Promise<StreamPlaybackSource[]>;
/**
* Resolve a channel-level live source to a program-input HLS URL plus a
* liveness verdict (WP6 go-live slice). Unlike getStreamPlaybackSources,
* which addresses one already-known stream by remoteId, this resolves by
* channel identity (channelId or externalUrl) — the input side of the
* desired-state take/start path.
*/
resolveLiveSource?(
connection: ConnectorConnection,
request: { channelId?: string; externalUrl?: string },
): Promise<LiveSourceResolution>;
getLiveChat?(
connection: ConnectorConnection,
request: { liveRemoteId: string; cursor?: string; limit: number },
): Promise<Page<LiveChatMessage>>;
sendLiveChatMessage?(
connection: ConnectorConnection,
request: { liveRemoteId: string; text: string },
): Promise<LiveChatMessage>;
moderateLiveChat?(
connection: ConnectorConnection,
request: { liveRemoteId: string; messageRemoteId: string; action: "delete" | "timeout" | "ban" },
): Promise<void>;
getAnalytics?(
connection: ConnectorConnection,
request: { subjectRemoteId?: string; from: Date; to: Date },
): Promise<{ values: Record<string, number>; series: { at: Date; values: Record<string, number> }[] }>;
subscribeWebhooks?(
connection: ConnectorConnection,
request: { topics: string[] },
): Promise<{ subscriptionId: string }>;
handleWebhook?(request: { headers: Record<string, string>; body: unknown }): Promise<NormalizedWebhookEvent>;
}

View file

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