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
27
packages/connector-sdk/package.json
Normal file
27
packages/connector-sdk/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
8
packages/connector-sdk/src/__probe.ts
Normal file
8
packages/connector-sdk/src/__probe.ts
Normal 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 };
|
||||
36
packages/connector-sdk/src/credentials.ts
Normal file
36
packages/connector-sdk/src/credentials.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
115
packages/connector-sdk/src/errors.ts
Normal file
115
packages/connector-sdk/src/errors.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
170
packages/connector-sdk/src/http.test.ts
Normal file
170
packages/connector-sdk/src/http.test.ts
Normal 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(); }
|
||||
});
|
||||
});
|
||||
217
packages/connector-sdk/src/http.ts
Normal file
217
packages/connector-sdk/src/http.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
8
packages/connector-sdk/src/index.ts
Normal file
8
packages/connector-sdk/src/index.ts
Normal 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";
|
||||
109
packages/connector-sdk/src/manifest.ts
Normal file
109
packages/connector-sdk/src/manifest.ts
Normal 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;
|
||||
}
|
||||
13
packages/connector-sdk/src/page.ts
Normal file
13
packages/connector-sdk/src/page.ts
Normal 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 };
|
||||
}
|
||||
104
packages/connector-sdk/src/rate-policy.test.ts
Normal file
104
packages/connector-sdk/src/rate-policy.test.ts
Normal 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);
|
||||
}
|
||||
145
packages/connector-sdk/src/rate-policy.ts
Normal file
145
packages/connector-sdk/src/rate-policy.ts
Normal 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`;
|
||||
}
|
||||
41
packages/connector-sdk/src/registry.ts
Normal file
41
packages/connector-sdk/src/registry.ts
Normal 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");
|
||||
123
packages/connector-sdk/src/testing/contract-search.test.ts
Normal file
123
packages/connector-sdk/src/testing/contract-search.test.ts
Normal 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"),
|
||||
});
|
||||
});
|
||||
136
packages/connector-sdk/src/testing/fixtures.ts
Normal file
136
packages/connector-sdk/src/testing/fixtures.ts
Normal 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>;
|
||||
225
packages/connector-sdk/src/testing/kits.ts
Normal file
225
packages/connector-sdk/src/testing/kits.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
233
packages/connector-sdk/src/types.ts
Normal file
233
packages/connector-sdk/src/types.ts
Normal 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>;
|
||||
}
|
||||
8
packages/connector-sdk/tsconfig.json
Normal file
8
packages/connector-sdk/tsconfig.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue