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; }