Mirror of https://github.com/roostorg/coop
github.com/roostorg/coop
1/**
2 * Integration plugin types for COOP.
3 *
4 * These types define the contract that third-party integration packages
5 * implement so adopters can install and configure them without adding
6 * every integration to the main COOP repo.
7 *
8 * Integration packages export a CoopIntegrationPlugin; adopters register
9 * them via an integrations config file (see CoopIntegrationsConfig).
10 */
11
12/** Unique identifier for the integration (e.g. "GOOGLE_CONTENT_SAFETY_API"). */
13export type IntegrationId = string;
14
15// ---------------------------------------------------------------------------
16// Model card (optional, per-integration metadata for display in the UI)
17// ---------------------------------------------------------------------------
18
19/**
20 * A single key-value row in a model card (e.g. "Release Date" -> "January 2026").
21 * Values are plain strings; the UI can linkify URLs or format as needed.
22 */
23export type ModelCardField = Readonly<{
24 label: string;
25 value: string;
26}>;
27
28/**
29 * A named group of fields within a section (e.g. "Basic Information" with
30 * Model Name, Version, Release Date). Rendered as a bold subheading + key-value list.
31 */
32export type ModelCardSubsection = Readonly<{
33 title: string;
34 fields: readonly ModelCardField[];
35}>;
36
37/**
38 * One collapsible section of a model card (e.g. "Model Details", "Training Data").
39 * Either subsections (with bold sub-headings) or top-level fields, or both.
40 */
41export type ModelCardSection = Readonly<{
42 /** Stable id for the section (e.g. "trainingData", "biasAndLimitations"). */
43 id: string;
44 /** Display title (e.g. "Model Details"). */
45 title: string;
46 /** Optional grouped key-value blocks with their own titles. */
47 subsections?: readonly ModelCardSubsection[];
48 /** Optional flat key-value list when there are no subsections. */
49 fields?: readonly ModelCardField[];
50}>;
51
52/**
53 * Model card: structured, JSON-backed metadata for an integration, so the UI
54 * can display it in a consistent but integration-specific way.
55 *
56 * Required: modelName and version (always shown). All sections are optional;
57 * the UI renders only those present. Sections can have subsections (e.g.
58 * "Basic Information", "Model Architecture") or flat fields.
59 */
60export type ModelCard = Readonly<{
61 /** Required. Display name of the model (e.g. "GPT-4"). */
62 modelName: string;
63 /** Required. Version string (e.g. "1.0.0" or "v0.0"). */
64 version: string;
65 /** Optional. Release date or similar (e.g. "January 2026"). */
66 releaseDate?: string;
67 /** Optional. Ordered list of sections; each can be collapsed/expanded in the UI. */
68 sections?: readonly ModelCardSection[];
69}>;
70
71/**
72 * Section ids that every integration's model card must include.
73 * Use assertModelCardHasRequiredSections() to validate at runtime.
74 */
75export const REQUIRED_MODEL_CARD_SECTION_IDS = [
76 'trainingData',
77 'policyAndTaxonomy',
78 'annotationMethodology',
79 'performanceBenchmarks',
80 'biasAndLimitations',
81 'implementationGuidance',
82 'relevantLinks',
83] as const;
84
85/**
86 * Asserts that a model card has at least the required sections.
87 * Call when registering integration manifests.
88 * @throws Error if any required section id is missing
89 */
90export function assertModelCardHasRequiredSections(card: ModelCard): void {
91 const sectionIds = new Set((card.sections ?? []).map((s) => s.id));
92 const missing = REQUIRED_MODEL_CARD_SECTION_IDS.filter(
93 (id) => !sectionIds.has(id),
94 );
95 if (missing.length > 0) {
96 throw new Error(
97 `Model card is missing required section(s): ${missing.map((id) => `"${id}"`).join(', ')}.`,
98 );
99 }
100}
101
102/**
103 * Describes a single configuration field for integrations that require
104 * user-supplied config (e.g. API keys or other settings). Used to generate or validate config forms.
105 */
106export type IntegrationConfigField = Readonly<{
107 /** Form field key (e.g. "apiKey", "truePercentage"). */
108 key: string;
109 /** Human-readable label for the field. */
110 label: string;
111 /** Whether the field is required. */
112 required: boolean;
113 /** Input type for the UI. */
114 inputType: 'text' | 'password' | 'json' | 'array';
115 /** Optional placeholder or hint. */
116 placeholder?: string;
117 /** Optional description for the field. */
118 description?: string;
119}>;
120
121/**
122 * Metadata and capability description for an integration.
123 * This is the stable, structured information shown to users (name, docs, logos, etc.).
124 */
125export type IntegrationManifest = Readonly<{
126 /** Unique integration id. Must be UPPER_SNAKE_CASE to align with GraphQL enums when used in COOP. */
127 id: IntegrationId;
128 /** Human-readable display name shown in the UI (e.g. signal modal, integration cards). Exposed as Signal.integrationTitle. */
129 name: string;
130 /** Semantic version of the integration plugin (e.g. "1.0.0"). */
131 version: string;
132 /** Short description for listings and tooltips. */
133 description?: string;
134 /** Link to documentation or product page. */
135 docsUrl?: string;
136 /** Whether this integration requires the user to supply config (e.g. API key). */
137 requiresConfig: boolean;
138 /**
139 * Schema for configuration fields when requiresConfig is true.
140 * Enables UI generation and validation without hardcoding per-integration forms.
141 */
142 configurationFields?: readonly IntegrationConfigField[];
143 /**
144 * Optional list of signal type ids this integration provides (e.g. "ZENTROPI_LABELER").
145 * Used by the platform to associate signals with this integration for display and gating.
146 */
147 signalTypeIds?: readonly string[];
148 /**
149 * Model card: structured metadata (model name, version, sections) for the UI.
150 * When present, the integration detail page renders it. Integrations must
151 * include all sections listed in REQUIRED_MODEL_CARD_SECTION_IDS; use
152 * assertModelCardHasRequiredSections() when registering.
153 */
154 modelCard?: ModelCard;
155 /**
156 * ------------------------------------------------------------
157 * LOGO/IMAGE SECTION:
158 * ------------------------------------------------------------
159 * The following logo/image sections are optional. If none provided will use a fallback Coop logo.
160 *
161 * Provide either logoUrl and logoWithBackgroundUrl or logoPath and logoWithBackgroundPath.
162 *
163 * If you provide logoPath and logoWithBackgroundPath, the server will serve the files at
164 * GET /api/v1/integration-logos/:integrationId and GET /api/v1/integration-logos/:integrationId/with-background
165 * and set logoUrl and logoWithBackgroundUrl accordingly.
166 * Usage: logoUrl/logoPath = plain logo (no background), used on the integrations page;
167 * logoWithBackgroundUrl/logoWithBackgroundPath = logo with background, used in signal modals.
168 * If you provide logoUrl and logoWithBackgroundUrl, the server will use those URLs directly.
169 * Prefered size: ~180x180px for logoUrl and ~120x120px for logoWithBackgroundUrl.
170 * Prefer a square or horizontal logo that scales well.
171 */
172 logoUrl?: string;
173 logoWithBackgroundUrl?: string;
174 logoPath?: string;
175 logoWithBackgroundPath?: string;
176}>;
177
178// ---------------------------------------------------------------------------
179// Plugin signals (for integrations that power routing/enforcement rules)
180// ---------------------------------------------------------------------------
181
182/** Context passed to plugin.createSignals() so the plugin can build signal instances with credential access. */
183export type PluginSignalContext = Readonly<{
184 /** Integration id (e.g. "ACME_API") from the plugin manifest. */
185 integrationId: string;
186 /** Get stored credential/config for an org. Resolves to the JSON stored for this integration. */
187 getCredential: (orgId: string) => Promise<Record<string, unknown>>;
188}>;
189
190/** Minimal signal descriptor returned by a plugin. The platform adapts this to its internal SignalBase. */
191export type PluginSignalDescriptor = Readonly<{
192 /** Stable signal type id (e.g. "ACME_MODERATION_SIGNAL"). Must match one of manifest.signalTypeIds. */
193 id: Readonly<{ type: string }>;
194 displayName: string;
195 description: string;
196 docsUrl: string | null;
197 recommendedThresholds: Readonly<{
198 highPrecisionThreshold: string | number;
199 highRecallThreshold: string | number;
200 }> | null;
201 supportedLanguages: readonly string[] | 'ALL';
202 pricingStructure: Readonly<{ type: 'FREE' | 'SUBSCRIPTION' }>;
203 eligibleInputs: readonly string[];
204 outputType: Readonly<{ scalarType: string }>;
205 getCost: () => number;
206 /** Run the signal. Input shape is platform-defined; result must have outputType and score. */
207 run: (input: unknown) => Promise<unknown>;
208 getDisabledInfo: (orgId: string) => Promise<
209 | { disabled: false; disabledMessage?: string }
210 | { disabled: true; disabledMessage: string }
211 >;
212 needsMatchingValues: boolean;
213 eligibleSubcategories: ReadonlyArray<{
214 id: string;
215 label: string;
216 description?: string;
217 childrenIds: readonly string[];
218 }>;
219 needsActionPenalties: boolean;
220 /** Integration id (same as context.integrationId). */
221 integration: string;
222 allowedInAutomatedRules: boolean;
223}>;
224
225/**
226 * Plugin contract that third-party integration packages must implement.
227 * Export this as the default export (or a named export) from the package.
228 *
229 * Example (in an integration package):
230 *
231 * const manifest: IntegrationManifest = { id: 'ACME_API', name: 'Acme API', ... };
232 * const plugin: CoopIntegrationPlugin = { manifest };
233 * export default plugin;
234 *
235 * To power routing/enforcement rules, also implement createSignals(context) and
236 * return one descriptor per manifest.signalTypeIds entry.
237 */
238export type CoopIntegrationPlugin = Readonly<{
239 manifest: IntegrationManifest;
240 /**
241 * Optional static config shape for this integration.
242 * If present, adopters can pass non-secret config in the integrations config file.
243 */
244 configSchema?: unknown;
245 /**
246 * Optional. If this integration provides signals for use in rules, implement this.
247 * Return one descriptor per signal type id listed in manifest.signalTypeIds.
248 * The platform will register these so they appear in the rule builder and can be used in conditions.
249 */
250 createSignals?: (
251 context: PluginSignalContext,
252 ) => ReadonlyArray<Readonly<{ signalTypeId: string; signal: PluginSignalDescriptor }>>;
253}>;
254
255/**
256 * Single entry in the adopters' integrations config file.
257 * Enables or disables a plugin and optionally passes static config.
258 */
259export type CoopIntegrationConfigEntry = Readonly<{
260 /** NPM package name (e.g. "@acme/coop-integration-acme") or path to a local module. */
261 package: string;
262 /** Whether this integration is enabled. Default true if omitted. */
263 enabled?: boolean;
264 /** Optional static config passed to the integration (no secrets here; use org credentials in-app). */
265 config?: Readonly<Record<string, unknown>>;
266}>;
267
268/**
269 * Root type for the integrations config file that adopters use to register
270 * plugin integrations. Can be JSON or a JS/TS module that exports this shape.
271 *
272 * Example integrations.config.json:
273 *
274 * {
275 * "integrations": [
276 * { "package": "@acme/coop-integration-acme", "enabled": true },
277 * { "package": "./local-integrations/foo", "config": { "endpoint": "https://..." } }
278 * ]
279 * }
280 */
281export type CoopIntegrationsConfig = Readonly<{
282 integrations: readonly CoopIntegrationConfigEntry[];
283}>;
284
285/**
286 * Shape of the config stored in the database for each integration (per org).
287 * Stored in a generic table as JSON: one row per (org_id, integration_id) with
288 * config as a JSON-serializable object. Each integration defines its own required
289 * fields via IntegrationManifest.configurationFields; the app validates and
290 * serializes/deserializes to this type.
291 *
292 * Only JSON-serializable values (no functions, symbols, or BigInt) should be
293 * included so the payload can be stored in a JSONB or TEXT column.
294 */
295export type StoredIntegrationConfigPayload = Readonly<Record<string, unknown>>;
296
297/**
298 * Type guard for CoopIntegrationPlugin.
299 */
300export function isCoopIntegrationPlugin(
301 value: unknown,
302): value is CoopIntegrationPlugin {
303 if (value == null || typeof value !== 'object') {
304 return false;
305 }
306 const o = value as Record<string, unknown>;
307 if (o.manifest == null || typeof o.manifest !== 'object') {
308 return false;
309 }
310 const m = o.manifest as Record<string, unknown>;
311 return (
312 typeof m.id === 'string' &&
313 typeof m.name === 'string' &&
314 typeof m.version === 'string' &&
315 typeof m.requiresConfig === 'boolean'
316 );
317}