Mirror of https://github.com/roostorg/coop github.com/roostorg/coop
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Initial Integrations plugin scaffolding: types, registry, logo API (#90)

* Integrations plugin: types, registry, logo API, and @roostorg/types from npm

Add types and server-side plugin loading (config, registry, logo routes).
Switch client and server to published @roostorg/types.

* add whiteline

* code review changes

* Integration Plugin Backend: migration, GraphQL API, and plugin signals (#91)

* Integration Plugin Backend: migration, GraphQL API, and plugin signals

* code review changes

* Integration plugin: client UI, example config, and example dependency (#92)

* Integration plugin: client UI, example config, and example dependency

* code review changes

* fix casting error

* betterer fixes

authored by

Juan Mrad and committed by
GitHub
01fc9674 c592436f

+2833 -424
+58 -58
.betterer.results
··· 149 149 [138, 52, 3, "Unexpected any. Specify a different type.", "193409811"], 150 150 [151, 52, 3, "Unexpected any. Specify a different type.", "193409811"] 151 151 ], 152 - "client/src/webpages/dashboard/rules/info/insights/RuleInsightsSamplesTable.tsx:1711845154": [ 153 - [612, 25, 3, "Unexpected any. Specify a different type.", "193409811"], 154 - [624, 32, 3, "Unexpected any. Specify a different type.", "193409811"], 155 - [748, 11, 3, "Unexpected any. Specify a different type.", "193409811"] 152 + "client/src/webpages/dashboard/rules/info/insights/RuleInsightsSamplesTable.tsx:1132615281": [ 153 + [611, 25, 3, "Unexpected any. Specify a different type.", "193409811"], 154 + [623, 32, 3, "Unexpected any. Specify a different type.", "193409811"], 155 + [747, 11, 3, "Unexpected any. Specify a different type.", "193409811"] 156 156 ], 157 157 "client/src/webpages/dashboard/rules/rule_form/ReportingRuleForm.tsx:3391496803": [ 158 158 [424, 38, 3, "Unexpected any. Specify a different type.", "193409811"], ··· 161 161 "client/src/webpages/dashboard/rules/rule_form/ReportingRuleFormReducers.tsx:2408070124": [ 162 162 [102, 27, 3, "Unexpected any. Specify a different type.", "193409811"] 163 163 ], 164 - "client/src/webpages/dashboard/rules/rule_form/RuleForm.tsx:2133305192": [ 165 - [901, 45, 3, "Unexpected any. Specify a different type.", "193409811"], 166 - [922, 45, 3, "Unexpected any. Specify a different type.", "193409811"], 167 - [949, 42, 3, "Unexpected any. Specify a different type.", "193409811"], 168 - [969, 42, 3, "Unexpected any. Specify a different type.", "193409811"] 164 + "client/src/webpages/dashboard/rules/rule_form/RuleForm.tsx:1069542000": [ 165 + [904, 45, 3, "Unexpected any. Specify a different type.", "193409811"], 166 + [925, 45, 3, "Unexpected any. Specify a different type.", "193409811"], 167 + [952, 42, 3, "Unexpected any. Specify a different type.", "193409811"], 168 + [972, 42, 3, "Unexpected any. Specify a different type.", "193409811"] 169 169 ], 170 170 "client/src/webpages/dashboard/rules/rule_form/RuleFormReducers.tsx:195582564": [ 171 171 [124, 27, 3, "Unexpected any. Specify a different type.", "193409811"] ··· 199 199 [88, 37, 3, "Unexpected any. Specify a different type.", "193409811"], 200 200 [89, 29, 3, "Unexpected any. Specify a different type.", "193409811"] 201 201 ], 202 - "server/condition_evaluator/conditionSet.ts:1666453120": [ 202 + "server/condition_evaluator/conditionSet.ts:3044213798": [ 203 203 [113, 42, 3, "Unexpected any. Specify a different type.", "193409811"], 204 204 [139, 50, 3, "Unexpected any. Specify a different type.", "193409811"] 205 205 ], 206 - "server/condition_evaluator/leafCondition.ts:3547992583": [ 206 + "server/condition_evaluator/leafCondition.ts:2941598758": [ 207 207 [212, 47, 3, "Unexpected any. Specify a different type.", "193409811"] 208 208 ], 209 - "server/graphql/datasources/RuleApi.ts:1760036803": [ 210 - [635, 31, 3, "Unexpected any. Specify a different type.", "193409811"], 211 - [775, 28, 3, "Unexpected any. Specify a different type.", "193409811"] 209 + "server/graphql/datasources/RuleApi.ts:1193935640": [ 210 + [634, 31, 3, "Unexpected any. Specify a different type.", "193409811"], 211 + [774, 28, 3, "Unexpected any. Specify a different type.", "193409811"] 212 212 ], 213 213 "server/graphql/datasources/UserApi.ts:3075210134": [ 214 214 [52, 22, 3, "Unexpected any. Specify a different type.", "193409811"], ··· 217 217 [78, 23, 3, "Unexpected any. Specify a different type.", "193409811"], 218 218 [78, 31, 3, "Unexpected any. Specify a different type.", "193409811"] 219 219 ], 220 - "server/graphql/modules/apiKey.ts:1280430358": [ 221 - [45, 13, 3, "Unexpected any. Specify a different type.", "193409811"], 222 - [46, 18, 3, "Unexpected any. Specify a different type.", "193409811"], 223 - [46, 27, 3, "Unexpected any. Specify a different type.", "193409811"], 224 - [46, 41, 3, "Unexpected any. Specify a different type.", "193409811"], 225 - [61, 16, 3, "Unexpected any. Specify a different type.", "193409811"], 226 - [62, 24, 3, "Unexpected any. Specify a different type.", "193409811"], 227 - [62, 40, 3, "Unexpected any. Specify a different type.", "193409811"], 228 - [62, 54, 3, "Unexpected any. Specify a different type.", "193409811"] 220 + "server/graphql/modules/apiKey.ts:923927854": [ 221 + [76, 13, 3, "Unexpected any. Specify a different type.", "193409811"], 222 + [77, 18, 3, "Unexpected any. Specify a different type.", "193409811"], 223 + [77, 27, 3, "Unexpected any. Specify a different type.", "193409811"], 224 + [77, 41, 3, "Unexpected any. Specify a different type.", "193409811"], 225 + [92, 16, 3, "Unexpected any. Specify a different type.", "193409811"], 226 + [93, 24, 3, "Unexpected any. Specify a different type.", "193409811"], 227 + [93, 40, 3, "Unexpected any. Specify a different type.", "193409811"], 228 + [93, 54, 3, "Unexpected any. Specify a different type.", "193409811"] 229 229 ], 230 - "server/graphql/modules/insights.ts:1817621512": [ 230 + "server/graphql/modules/insights.ts:3497054019": [ 231 231 [194, 11, 3, "Unexpected any. Specify a different type.", "193409811"], 232 232 [224, 11, 3, "Unexpected any. Specify a different type.", "193409811"], 233 233 [269, 11, 3, "Unexpected any. Specify a different type.", "193409811"], ··· 263 263 [202, 32, 3, "Unexpected any. Specify a different type.", "193409811"], 264 264 [202, 38, 3, "Unexpected any. Specify a different type.", "193409811"] 265 265 ], 266 - "server/routes/index.ts:266628659": [ 267 - [13, 16, 3, "Unexpected any. Specify a different type.", "193409811"], 268 - [13, 21, 3, "Unexpected any. Specify a different type.", "193409811"] 266 + "server/routes/index.ts:1040057535": [ 267 + [14, 16, 3, "Unexpected any. Specify a different type.", "193409811"], 268 + [14, 21, 3, "Unexpected any. Specify a different type.", "193409811"] 269 269 ], 270 270 "server/routes/policies/PoliciesRoutes.ts:1959143587": [ 271 271 [14, 6, 3, "Unexpected any. Specify a different type.", "193409811"] ··· 398 398 [158, 29, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], 399 399 [396, 23, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"] 400 400 ], 401 - "client/src/webpages/dashboard/rules/rule_form/RuleForm.tsx:2133305192": [ 402 - [673, 52, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], 403 - [1011, 50, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"] 401 + "client/src/webpages/dashboard/rules/rule_form/RuleForm.tsx:1069542000": [ 402 + [676, 52, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], 403 + [1014, 50, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"] 404 404 ], 405 405 "client/src/webpages/dashboard/rules/rule_form/RuleFormCondition.tsx:453078395": [ 406 406 [122, 25, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], ··· 435 435 "client/src/webpages/dashboard/rules/rule_form/condition/signal/RuleFormConditionSignalArgs.tsx:2792044510": [ 436 436 [7, 29, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"] 437 437 ], 438 - "client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModal.tsx:2596084458": [ 438 + "client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModal.tsx:1236028107": [ 439 439 [13, 14, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], 440 440 [14, 27, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], 441 - [18, 19, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], 442 - [32, 59, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], 443 - [37, 13, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], 444 - [82, 20, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], 445 - [96, 41, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], 446 - [99, 35, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"] 441 + [16, 19, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], 442 + [29, 59, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], 443 + [34, 13, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], 444 + [79, 20, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], 445 + [93, 41, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], 446 + [96, 35, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"] 447 447 ], 448 - "client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalMenuItem.tsx:1243180955": [ 449 - [12, 35, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], 450 - [23, 42, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], 451 - [40, 10, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], 452 - [42, 20, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"] 448 + "client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalMenuItem.tsx:1152066983": [ 449 + [13, 35, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], 450 + [34, 42, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], 451 + [51, 10, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], 452 + [53, 20, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"] 453 453 ], 454 - "client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalSignalDetailView.tsx:821193771": [ 454 + "client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalSignalDetailView.tsx:1680940259": [ 455 455 [15, 10, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], 456 456 [18, 12, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], 457 - [94, 39, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"] 457 + [112, 39, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"] 458 458 ], 459 - "client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalSignalGallery.tsx:2104107710": [ 459 + "client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalSignalGallery.tsx:550203540": [ 460 460 [11, 14, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], 461 461 [12, 27, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], 462 462 [13, 33, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"] 463 463 ], 464 - "client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalSubcategoryGallery.tsx:3258286357": [ 464 + "client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalSubcategoryGallery.tsx:531331643": [ 465 465 [12, 10, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"] 466 466 ], 467 467 "client/src/webpages/dashboard/rules/types.ts:4119145711": [ ··· 498 498 [122, 17, 6, "When a function \`x\` is written inline and passed as an argument, it\'s usually better not to write explicit type annotations on \`x\`\'s arguments because the argument types should be able to be inferred, and the inferred type will usually be more accurate than what you\'d write manually. Plus, the inferred type will automatically update.\\n\\nIf the type for x\'s arguments is not being correctly inferred, that suggests an issue with the type definition of the function that \`x\` is being passed to.", "1449682699"], 499 499 [125, 12, 15, "When a function \`x\` is written inline and passed as an argument, it\'s usually better not to write explicit type annotations on \`x\`\'s arguments because the argument types should be able to be inferred, and the inferred type will usually be more accurate than what you\'d write manually. Plus, the inferred type will automatically update.\\n\\nIf the type for x\'s arguments is not being correctly inferred, that suggests an issue with the type definition of the function that \`x\` is being passed to.", "2927082151"] 500 500 ], 501 - "client/src/webpages/dashboard/rules/info/insights/RuleInsightsSamplesTable.tsx:1711845154": [ 502 - [405, 15, 18, "When a function \`x\` is written inline and passed as an argument, it\'s usually better not to write explicit type annotations on \`x\`\'s arguments because the argument types should be able to be inferred, and the inferred type will usually be more accurate than what you\'d write manually. Plus, the inferred type will automatically update.\\n\\nIf the type for x\'s arguments is not being correctly inferred, that suggests an issue with the type definition of the function that \`x\` is being passed to.", "4144316489"] 501 + "client/src/webpages/dashboard/rules/info/insights/RuleInsightsSamplesTable.tsx:1132615281": [ 502 + [404, 15, 18, "When a function \`x\` is written inline and passed as an argument, it\'s usually better not to write explicit type annotations on \`x\`\'s arguments because the argument types should be able to be inferred, and the inferred type will usually be more accurate than what you\'d write manually. Plus, the inferred type will automatically update.\\n\\nIf the type for x\'s arguments is not being correctly inferred, that suggests an issue with the type definition of the function that \`x\` is being passed to.", "4144316489"] 503 503 ], 504 - "client/src/webpages/dashboard/rules/rule_form/RuleForm.tsx:2133305192": [ 505 - [1008, 11, 10, "When a function \`x\` is written inline and passed as an argument, it\'s usually better not to write explicit type annotations on \`x\`\'s arguments because the argument types should be able to be inferred, and the inferred type will usually be more accurate than what you\'d write manually. Plus, the inferred type will automatically update.\\n\\nIf the type for x\'s arguments is not being correctly inferred, that suggests an issue with the type definition of the function that \`x\` is being passed to.", "3776056839"] 504 + "client/src/webpages/dashboard/rules/rule_form/RuleForm.tsx:1069542000": [ 505 + [1011, 11, 10, "When a function \`x\` is written inline and passed as an argument, it\'s usually better not to write explicit type annotations on \`x\`\'s arguments because the argument types should be able to be inferred, and the inferred type will usually be more accurate than what you\'d write manually. Plus, the inferred type will automatically update.\\n\\nIf the type for x\'s arguments is not being correctly inferred, that suggests an issue with the type definition of the function that \`x\` is being passed to.", "3776056839"] 506 506 ], 507 507 "client/src/webpages/dashboard/rules/rule_form/RuleFormReducers.tsx:195582564": [ 508 508 [669, 5, 24, "When a function \`x\` is written inline and passed as an argument, it\'s usually better not to write explicit type annotations on \`x\`\'s arguments because the argument types should be able to be inferred, and the inferred type will usually be more accurate than what you\'d write manually. Plus, the inferred type will automatically update.\\n\\nIf the type for x\'s arguments is not being correctly inferred, that suggests an issue with the type definition of the function that \`x\` is being passed to.", "1730074379"], ··· 670 670 "client/src/webpages/dashboard/rules/info/insights/RuleInsightsSamplesPlayVideoButton.tsx:3799970987": [ 671 671 [0, 0, 53, "\'@ant-design/icons\' import is restricted from being used. AntDesign icons are now deprecated in our codebase. Please use line icons instead.", "1828321615"] 672 672 ], 673 - "client/src/webpages/dashboard/rules/info/insights/RuleInsightsSamplesTable.tsx:1711845154": [ 673 + "client/src/webpages/dashboard/rules/info/insights/RuleInsightsSamplesTable.tsx:1132615281": [ 674 674 [0, 0, 92, "\'@ant-design/icons\' import is restricted from being used. AntDesign icons are now deprecated in our codebase. Please use line icons instead.", "3155850297"] 675 675 ], 676 676 "client/src/webpages/dashboard/rules/info/insights/RuleInsightsSamplesVideoModal.tsx:2468150091": [ ··· 682 682 "client/src/webpages/dashboard/rules/rule_form/ReportingRuleForm.tsx:3391496803": [ 683 683 [2, 0, 49, "\'@ant-design/icons\' import is restricted from being used. AntDesign icons are now deprecated in our codebase. Please use line icons instead.", "3206710238"] 684 684 ], 685 - "client/src/webpages/dashboard/rules/rule_form/RuleForm.tsx:2133305192": [ 685 + "client/src/webpages/dashboard/rules/rule_form/RuleForm.tsx:1069542000": [ 686 686 [4, 0, 75, "\'@ant-design/icons\' import is restricted from being used. AntDesign icons are now deprecated in our codebase. Please use line icons instead.", "2372850505"] 687 687 ], 688 688 "client/src/webpages/dashboard/rules/rule_form/RuleFormCondition.tsx:453078395": [ ··· 700 700 "client/src/webpages/dashboard/rules/rule_form/condition/threshold/RuleFormConditionThreshold.tsx:2990530520": [ 701 701 [0, 0, 62, "\'@ant-design/icons\' import is restricted from being used. AntDesign icons are now deprecated in our codebase. Please use line icons instead.", "2914458485"] 702 702 ], 703 - "client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalMenuItem.tsx:1243180955": [ 703 + "client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalMenuItem.tsx:1152066983": [ 704 704 [0, 0, 50, "\'@ant-design/icons\' import is restricted from being used. AntDesign icons are now deprecated in our codebase. Please use line icons instead.", "2719154628"] 705 705 ], 706 706 "client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalNoSearchResults.tsx:2700507085": [ 707 707 [0, 0, 51, "\'@ant-design/icons\' import is restricted from being used. AntDesign icons are now deprecated in our codebase. Please use line icons instead.", "3222053322"] 708 708 ], 709 - "client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalSignalGallery.tsx:2104107710": [ 709 + "client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalSignalGallery.tsx:550203540": [ 710 710 [1, 0, 51, "\'@ant-design/icons\' import is restricted from being used. AntDesign icons are now deprecated in our codebase. Please use line icons instead.", "3222053322"] 711 711 ], 712 - "client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalSubcategoryGallery.tsx:3258286357": [ 712 + "client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalSubcategoryGallery.tsx:531331643": [ 713 713 [0, 0, 51, "\'@ant-design/icons\' import is restricted from being used. AntDesign icons are now deprecated in our codebase. Please use line icons instead.", "3222053322"] 714 714 ] 715 715 }` ··· 808 808 [0, 0, 87, "\'@/icons/lni/Direction/chevron-down.svg\' import is restricted from being used by a pattern.", "3761457464"], 809 809 [1, 0, 83, "\'@/icons/lni/Direction/chevron-up.svg\' import is restricted from being used by a pattern.", "1296196504"] 810 810 ], 811 - "client/src/webpages/dashboard/overview/Overview.tsx:4156638298": [ 811 + "client/src/webpages/dashboard/overview/Overview.tsx:2095261868": [ 812 812 [10, 0, 123, "\'@/icons\' import is restricted from being used.", "3974909531"] 813 813 ], 814 - "client/src/webpages/dashboard/overview/OverviewCard.tsx:3637648116": [ 814 + "client/src/webpages/dashboard/overview/OverviewCard.tsx:1601864003": [ 815 815 [8, 0, 61, "\'@/icons\' import is restricted from being used.", "8406751"], 816 816 [9, 0, 85, "\'@/icons/lni/Direction/arrow-right.svg\' import is restricted from being used by a pattern.", "427462392"], 817 817 [10, 0, 97, "\'@/icons/lni/Direction/arrows-horizontal.svg\' import is restricted from being used by a pattern.", "3504538040"] ··· 847 847 [0, 0, 88, "\'@/icons/lni/Web and Technology/copy-alt.svg\' import is restricted from being used by a pattern.", "654958816"], 848 848 [1, 0, 90, "\'@/icons/lni/Web and Technology/trash-can.svg\' import is restricted from being used by a pattern.", "3521839680"] 849 849 ], 850 - "client/src/webpages/dashboard/rules/rule_form/RuleForm.tsx:2133305192": [ 850 + "client/src/webpages/dashboard/rules/rule_form/RuleForm.tsx:1069542000": [ 851 851 [2, 0, 88, "\'@/icons/lni/Web and Technology/copy-alt.svg\' import is restricted from being used by a pattern.", "654958816"], 852 852 [3, 0, 90, "\'@/icons/lni/Web and Technology/trash-can.svg\' import is restricted from being used by a pattern.", "3521839680"] 853 853 ]
+22
.devops/migrator/src/scripts/api-server-pg/2026.02.23T00.00.00.add_generic_integration_configs.sql
··· 1 + -- Generic integration configs table: one row per (org_id, integration_id). 2 + -- Config is stored as JSONB so each integration can define its own credential/config 3 + -- shape without new tables or migrations. The app serializes/deserializes using 4 + -- the integration's manifest (e.g. credentialFields). 5 + 6 + CREATE TABLE signal_auth_service.integration_configs ( 7 + org_id character varying(255) NOT NULL, 8 + integration_id character varying(255) NOT NULL, 9 + config JSONB NOT NULL DEFAULT '{}', 10 + created_at timestamp with time zone DEFAULT now() NOT NULL, 11 + updated_at timestamp with time zone DEFAULT now() NOT NULL 12 + ); 13 + 14 + ALTER TABLE signal_auth_service.integration_configs OWNER TO postgres; 15 + 16 + ALTER TABLE ONLY signal_auth_service.integration_configs 17 + ADD CONSTRAINT integration_configs_pkey PRIMARY KEY (org_id, integration_id); 18 + 19 + ALTER TABLE ONLY signal_auth_service.integration_configs 20 + ADD CONSTRAINT integration_configs_org_id_fkey FOREIGN KEY (org_id) REFERENCES public.orgs(id) ON DELETE CASCADE; 21 + 22 + COMMENT ON TABLE signal_auth_service.integration_configs IS 'Extensible per-org integration credentials/config. config is JSON; shape is defined by each integration (e.g. via CoopIntegrationPlugin manifest.credentialFields).';
+4 -4
client/package-lock.json
··· 29 29 "@radix-ui/react-slider": "^1.2.0", 30 30 "@radix-ui/react-switch": "^1.1.0", 31 31 "@radix-ui/react-tooltip": "^1.1.2", 32 - "@roostorg/types": "^1.0.49", 32 + "@roostorg/types": "^1.1.1", 33 33 "@tailwindcss/container-queries": "^0.1.1", 34 34 "@tailwindcss/forms": "^0.5.7", 35 35 "@tailwindcss/typography": "^0.5.13", ··· 6077 6077 } 6078 6078 }, 6079 6079 "node_modules/@roostorg/types": { 6080 - "version": "1.0.49", 6081 - "resolved": "https://registry.npmjs.org/@roostorg/types/-/types-1.0.49.tgz", 6082 - "integrity": "sha512-yrilSnPzP/KPryazRQbufMuwfBTLnne08TdkZqUW2QObh6oHkyDGwShQSLkWqRzKJxo+VSFZlbHGXdVUeGS3LQ==", 6080 + "version": "1.1.1", 6081 + "resolved": "https://registry.npmjs.org/@roostorg/types/-/types-1.1.1.tgz", 6082 + "integrity": "sha512-NhPYlG27wAQaD7AzWkL3LJHu52/QfK8lt9QMahUx7fbRtB4fYILy4fGcLQvt45gNQANoU78evW1UJftAB0B89Q==", 6083 6083 "license": "ISC", 6084 6084 "dependencies": { 6085 6085 "date-fns": "^2.29.3",
+2 -3
client/package.json
··· 37 37 "@radix-ui/react-slider": "^1.2.0", 38 38 "@radix-ui/react-switch": "^1.1.0", 39 39 "@radix-ui/react-tooltip": "^1.1.2", 40 - "@roostorg/types": "^1.0.49", 40 + "@roostorg/types": "^1.1.1", 41 41 "@tailwindcss/container-queries": "^0.1.1", 42 42 "@tailwindcss/forms": "^0.5.7", 43 43 "@tailwindcss/typography": "^0.5.13", ··· 161 161 "**/*.test.(js|ts|tsx)" 162 162 ], 163 163 "moduleNameMapper": { 164 - "^@/(.*)$": "<rootDir>/src/$1", 165 - "^@roostorg/types(.*)$": "<rootDir>/../types/$1" 164 + "^@/(.*)$": "<rootDir>/src/$1" 166 165 } 167 166 }, 168 167 "proxy": "http://localhost:8080"
+384 -62
client/src/graphql/generated.ts
··· 401 401 readonly id: Scalars['ID']; 402 402 readonly name?: InputMaybe<Scalars['String']>; 403 403 readonly subcategory?: InputMaybe<Scalars['String']>; 404 - readonly type: GQLSignalType; 404 + readonly type: Scalars['String']; 405 405 }; 406 406 407 407 export type GQLConditionMatchingValuesInput = { ··· 1259 1259 export type GQLIntegrationApiCredential = 1260 1260 | GQLGoogleContentSafetyApiIntegrationApiCredential 1261 1261 | GQLOpenAiIntegrationApiCredential 1262 + | GQLPluginIntegrationApiCredential 1262 1263 | GQLZentropiIntegrationApiCredential; 1263 1264 1264 1265 export type GQLIntegrationApiCredentialInput = { ··· 1270 1271 export type GQLIntegrationConfig = { 1271 1272 readonly __typename: 'IntegrationConfig'; 1272 1273 readonly apiCredential: GQLIntegrationApiCredential; 1273 - readonly name: GQLIntegration; 1274 + readonly docsUrl: Scalars['String']; 1275 + readonly logoUrl?: Maybe<Scalars['String']>; 1276 + readonly logoWithBackgroundUrl?: Maybe<Scalars['String']>; 1277 + readonly modelCard: GQLModelCard; 1278 + readonly modelCardLearnMoreUrl?: Maybe<Scalars['String']>; 1279 + readonly name: Scalars['String']; 1280 + readonly requiresConfig: Scalars['Boolean']; 1281 + readonly title: Scalars['String']; 1274 1282 }; 1275 1283 1276 1284 export type GQLIntegrationConfigQueryResponse = ··· 1310 1318 readonly status: Scalars['Int']; 1311 1319 readonly title: Scalars['String']; 1312 1320 readonly type: ReadonlyArray<Scalars['String']>; 1321 + }; 1322 + 1323 + export type GQLIntegrationMetadata = { 1324 + readonly __typename: 'IntegrationMetadata'; 1325 + readonly docsUrl: Scalars['String']; 1326 + readonly logoUrl?: Maybe<Scalars['String']>; 1327 + readonly logoWithBackgroundUrl?: Maybe<Scalars['String']>; 1328 + readonly name: Scalars['String']; 1329 + readonly requiresConfig: Scalars['Boolean']; 1330 + readonly title: Scalars['String']; 1313 1331 }; 1314 1332 1315 1333 export type GQLIntegrationNoInputCredentialsError = GQLError & { ··· 2094 2112 2095 2113 export type GQLMetricsTimeDivisionOptions = 2096 2114 (typeof GQLMetricsTimeDivisionOptions)[keyof typeof GQLMetricsTimeDivisionOptions]; 2115 + export type GQLModelCard = { 2116 + readonly __typename: 'ModelCard'; 2117 + readonly modelName: Scalars['String']; 2118 + readonly releaseDate?: Maybe<Scalars['String']>; 2119 + readonly sections?: Maybe<ReadonlyArray<GQLModelCardSection>>; 2120 + readonly version: Scalars['String']; 2121 + }; 2122 + 2123 + export type GQLModelCardField = { 2124 + readonly __typename: 'ModelCardField'; 2125 + readonly label: Scalars['String']; 2126 + readonly value: Scalars['String']; 2127 + }; 2128 + 2129 + export type GQLModelCardSection = { 2130 + readonly __typename: 'ModelCardSection'; 2131 + readonly fields?: Maybe<ReadonlyArray<GQLModelCardField>>; 2132 + readonly id: Scalars['String']; 2133 + readonly subsections?: Maybe<ReadonlyArray<GQLModelCardSubsection>>; 2134 + readonly title: Scalars['String']; 2135 + }; 2136 + 2137 + export type GQLModelCardSubsection = { 2138 + readonly __typename: 'ModelCardSubsection'; 2139 + readonly fields: ReadonlyArray<GQLModelCardField>; 2140 + readonly title: Scalars['String']; 2141 + }; 2142 + 2097 2143 export type GQLModeratorSafetySettingsInput = { 2098 2144 readonly moderatorSafetyBlurLevel: Scalars['Int']; 2099 2145 readonly moderatorSafetyGrayscale: Scalars['Boolean']; ··· 2267 2313 readonly setModeratorSafetySettings?: Maybe<GQLSetModeratorSafetySettingsSuccessResponse>; 2268 2314 readonly setMrtChartConfigurationSettings?: Maybe<GQLSetMrtChartConfigurationSettingsSuccessResponse>; 2269 2315 readonly setOrgDefaultSafetySettings?: Maybe<GQLSetModeratorSafetySettingsSuccessResponse>; 2316 + readonly setPluginIntegrationConfig: GQLSetIntegrationConfigResponse; 2270 2317 readonly signUp: GQLSignUpResponse; 2271 2318 readonly submitManualReviewDecision: GQLSubmitDecisionResponse; 2272 2319 readonly updateAccountInfo?: Maybe<Scalars['Boolean']>; ··· 2519 2566 orgDefaultSafetySettings: GQLModeratorSafetySettingsInput; 2520 2567 }; 2521 2568 2569 + export type GQLMutationSetPluginIntegrationConfigArgs = { 2570 + input: GQLSetPluginIntegrationConfigInput; 2571 + }; 2572 + 2522 2573 export type GQLMutationSignUpArgs = { 2523 2574 input: GQLSignUpInput; 2524 2575 }; ··· 2968 3019 readonly southwestCorner: GQLLatLngInput; 2969 3020 }; 2970 3021 3022 + export type GQLPluginIntegrationApiCredential = { 3023 + readonly __typename: 'PluginIntegrationApiCredential'; 3024 + readonly credential: Scalars['JSONObject']; 3025 + }; 3026 + 2971 3027 export type GQLPolicy = { 2972 3028 readonly __typename: 'Policy'; 2973 3029 readonly applyUserStrikeCountConfigToChildren?: Maybe<Scalars['Boolean']>; ··· 3036 3092 readonly allRuleInsights: GQLAllRuleInsights; 3037 3093 readonly apiKey: Scalars['String']; 3038 3094 readonly appealSettings?: Maybe<GQLAppealSettings>; 3095 + readonly availableIntegrations: ReadonlyArray<GQLIntegrationMetadata>; 3039 3096 readonly getCommentsForJob: ReadonlyArray<GQLManualReviewJobComment>; 3040 3097 readonly getDecidedJob?: Maybe<GQLManualReviewJob>; 3041 3098 readonly getDecidedJobFromJobId?: Maybe<GQLManualReviewJobWithDecisions>; ··· 3178 3235 }; 3179 3236 3180 3237 export type GQLQueryIntegrationConfigArgs = { 3181 - name: GQLIntegration; 3238 + name: Scalars['String']; 3182 3239 }; 3183 3240 3184 3241 export type GQLQueryInviteUserTokenArgs = { ··· 3853 3910 readonly _?: Maybe<Scalars['Boolean']>; 3854 3911 }; 3855 3912 3913 + export type GQLSetPluginIntegrationConfigInput = { 3914 + readonly credential: Scalars['JSONObject']; 3915 + readonly integrationId: Scalars['String']; 3916 + }; 3917 + 3856 3918 export type GQLSetUserStrikeThresholdInput = { 3857 3919 readonly actions: ReadonlyArray<Scalars['String']>; 3858 3920 readonly threshold: Scalars['Int']; ··· 3901 3963 readonly eligibleInputs: ReadonlyArray<GQLSignalInputType>; 3902 3964 readonly eligibleSubcategories: ReadonlyArray<GQLSignalSubcategory>; 3903 3965 readonly id: Scalars['ID']; 3904 - readonly integration?: Maybe<GQLIntegration>; 3966 + readonly integration?: Maybe<Scalars['String']>; 3967 + /** Logo URL for the integration. Null if not set or when signal has no integration. */ 3968 + readonly integrationLogoUrl?: Maybe<Scalars['String']>; 3969 + /** Logo-with-background URL for the integration. Null if not set or when signal has no integration. */ 3970 + readonly integrationLogoWithBackgroundUrl?: Maybe<Scalars['String']>; 3971 + /** Display name for the signal’s integration (from registry manifest). Null when signal has no integration. */ 3972 + readonly integrationTitle?: Maybe<Scalars['String']>; 3905 3973 readonly name: Scalars['String']; 3906 3974 readonly outputType: GQLSignalOutputType; 3907 3975 readonly pricingStructure: GQLSignalPricingStructure; ··· 3909 3977 readonly shouldPromptForMatchingValues: Scalars['Boolean']; 3910 3978 readonly subcategory?: Maybe<Scalars['String']>; 3911 3979 readonly supportedLanguages: GQLSupportedLanguages; 3912 - readonly type: GQLSignalType; 3980 + readonly type: Scalars['String']; 3913 3981 }; 3914 3982 3915 3983 export type GQLSignalArgs = GQLAggregationSignalArgs; ··· 4005 4073 export type GQLSignalType = (typeof GQLSignalType)[keyof typeof GQLSignalType]; 4006 4074 export type GQLSignalWithScore = { 4007 4075 readonly __typename: 'SignalWithScore'; 4008 - readonly integration?: Maybe<GQLIntegration>; 4076 + readonly integration?: Maybe<Scalars['String']>; 4009 4077 readonly score: Scalars['String']; 4010 4078 readonly signalName: Scalars['String']; 4011 4079 readonly subcategory?: Maybe<Scalars['String']>; ··· 5653 5721 readonly __typename: 'SetIntegrationConfigSuccessResponse'; 5654 5722 readonly config: { 5655 5723 readonly __typename: 'IntegrationConfig'; 5656 - readonly name: GQLIntegration; 5724 + readonly name: string; 5725 + }; 5726 + }; 5727 + }; 5728 + 5729 + export type GQLSetPluginIntegrationConfigMutationVariables = Exact<{ 5730 + input: GQLSetPluginIntegrationConfigInput; 5731 + }>; 5732 + 5733 + export type GQLSetPluginIntegrationConfigMutation = { 5734 + readonly __typename: 'Mutation'; 5735 + readonly setPluginIntegrationConfig: 5736 + | { 5737 + readonly __typename: 'IntegrationConfigTooManyCredentialsError'; 5738 + readonly title: string; 5739 + } 5740 + | { 5741 + readonly __typename: 'IntegrationEmptyInputCredentialsError'; 5742 + readonly title: string; 5743 + } 5744 + | { 5745 + readonly __typename: 'IntegrationNoInputCredentialsError'; 5746 + readonly title: string; 5747 + } 5748 + | { 5749 + readonly __typename: 'SetIntegrationConfigSuccessResponse'; 5750 + readonly config: { 5751 + readonly __typename: 'IntegrationConfig'; 5752 + readonly name: string; 5657 5753 }; 5658 5754 }; 5659 5755 }; 5660 5756 5661 5757 export type GQLIntegrationConfigQueryVariables = Exact<{ 5662 - name: GQLIntegration; 5758 + name: Scalars['String']; 5663 5759 }>; 5664 5760 5665 5761 export type GQLIntegrationConfigQuery = { ··· 5669 5765 readonly __typename: 'IntegrationConfigSuccessResult'; 5670 5766 readonly config?: { 5671 5767 readonly __typename: 'IntegrationConfig'; 5672 - readonly name: GQLIntegration; 5768 + readonly name: string; 5769 + readonly title: string; 5770 + readonly docsUrl: string; 5771 + readonly requiresConfig: boolean; 5772 + readonly logoUrl?: string | null; 5773 + readonly logoWithBackgroundUrl?: string | null; 5774 + readonly modelCardLearnMoreUrl?: string | null; 5775 + readonly modelCard: { 5776 + readonly __typename: 'ModelCard'; 5777 + readonly modelName: string; 5778 + readonly version: string; 5779 + readonly releaseDate?: string | null; 5780 + readonly sections?: ReadonlyArray<{ 5781 + readonly __typename: 'ModelCardSection'; 5782 + readonly id: string; 5783 + readonly title: string; 5784 + readonly subsections?: ReadonlyArray<{ 5785 + readonly __typename: 'ModelCardSubsection'; 5786 + readonly title: string; 5787 + readonly fields: ReadonlyArray<{ 5788 + readonly __typename: 'ModelCardField'; 5789 + readonly label: string; 5790 + readonly value: string; 5791 + }>; 5792 + }> | null; 5793 + readonly fields?: ReadonlyArray<{ 5794 + readonly __typename: 'ModelCardField'; 5795 + readonly label: string; 5796 + readonly value: string; 5797 + }> | null; 5798 + }> | null; 5799 + }; 5673 5800 readonly apiCredential: 5674 5801 | { 5675 5802 readonly __typename: 'GoogleContentSafetyApiIntegrationApiCredential'; ··· 5680 5807 readonly apiKey: string; 5681 5808 } 5682 5809 | { 5810 + readonly __typename: 'PluginIntegrationApiCredential'; 5811 + readonly credential: JsonObject; 5812 + } 5813 + | { 5683 5814 readonly __typename: 'ZentropiIntegrationApiCredential'; 5684 5815 readonly apiKey: string; 5685 5816 readonly labelerVersions: ReadonlyArray<{ ··· 5704 5835 readonly __typename: 'Org'; 5705 5836 readonly integrationConfigs: ReadonlyArray<{ 5706 5837 readonly __typename: 'IntegrationConfig'; 5707 - readonly name: GQLIntegration; 5838 + readonly name: string; 5708 5839 }>; 5709 5840 } | null; 5841 + }; 5842 + 5843 + export type GQLAvailableIntegrationsQueryVariables = Exact<{ 5844 + [key: string]: never; 5845 + }>; 5846 + 5847 + export type GQLAvailableIntegrationsQuery = { 5848 + readonly __typename: 'Query'; 5849 + readonly availableIntegrations: ReadonlyArray<{ 5850 + readonly __typename: 'IntegrationMetadata'; 5851 + readonly name: string; 5852 + readonly title: string; 5853 + readonly docsUrl: string; 5854 + readonly requiresConfig: boolean; 5855 + readonly logoUrl?: string | null; 5856 + readonly logoWithBackgroundUrl?: string | null; 5857 + }>; 5710 5858 }; 5711 5859 5712 5860 export type GQLInvestigationItemTypesQueryVariables = Exact<{ ··· 6429 6577 readonly signal?: { 6430 6578 readonly __typename: 'Signal'; 6431 6579 readonly id: string; 6432 - readonly type: GQLSignalType; 6580 + readonly type: string; 6433 6581 readonly name: string; 6434 6582 readonly subcategory?: string | null; 6435 6583 readonly args?: { ··· 6506 6654 readonly signal?: { 6507 6655 readonly __typename: 'Signal'; 6508 6656 readonly id: string; 6509 - readonly type: GQLSignalType; 6657 + readonly type: string; 6510 6658 readonly name: string; 6511 6659 readonly subcategory?: string | null; 6512 6660 readonly args?: { ··· 16934 17082 readonly signal?: { 16935 17083 readonly __typename: 'Signal'; 16936 17084 readonly id: string; 16937 - readonly type: GQLSignalType; 17085 + readonly type: string; 16938 17086 readonly name: string; 16939 17087 readonly subcategory?: string | null; 16940 17088 readonly args?: { ··· 17010 17158 readonly signal?: { 17011 17159 readonly __typename: 'Signal'; 17012 17160 readonly id: string; 17013 - readonly type: GQLSignalType; 17161 + readonly type: string; 17014 17162 readonly name: string; 17015 17163 readonly subcategory?: string | null; 17016 17164 readonly args?: { ··· 17282 17430 readonly signal?: { 17283 17431 readonly __typename: 'Signal'; 17284 17432 readonly id: string; 17285 - readonly type: GQLSignalType; 17433 + readonly type: string; 17286 17434 readonly name: string; 17287 17435 readonly subcategory?: string | null; 17288 17436 readonly args?: { ··· 17358 17506 readonly signal?: { 17359 17507 readonly __typename: 'Signal'; 17360 17508 readonly id: string; 17361 - readonly type: GQLSignalType; 17509 + readonly type: string; 17362 17510 readonly name: string; 17363 17511 readonly subcategory?: string | null; 17364 17512 readonly args?: { ··· 17411 17559 readonly signals: ReadonlyArray<{ 17412 17560 readonly __typename: 'Signal'; 17413 17561 readonly id: string; 17414 - readonly type: GQLSignalType; 17562 + readonly type: string; 17415 17563 readonly name: string; 17416 - readonly integration?: GQLIntegration | null; 17564 + readonly integration?: string | null; 17565 + readonly integrationTitle?: string | null; 17566 + readonly integrationLogoUrl?: string | null; 17567 + readonly integrationLogoWithBackgroundUrl?: string | null; 17417 17568 readonly docsUrl?: string | null; 17418 17569 readonly description: string; 17419 17570 readonly eligibleInputs: ReadonlyArray<GQLSignalInputType>; ··· 18408 18559 readonly signal?: { 18409 18560 readonly __typename: 'Signal'; 18410 18561 readonly id: string; 18411 - readonly type: GQLSignalType; 18562 + readonly type: string; 18412 18563 readonly name: string; 18413 18564 readonly subcategory?: string | null; 18414 18565 readonly args?: { ··· 18484 18635 readonly signal?: { 18485 18636 readonly __typename: 'Signal'; 18486 18637 readonly id: string; 18487 - readonly type: GQLSignalType; 18638 + readonly type: string; 18488 18639 readonly name: string; 18489 18640 readonly subcategory?: string | null; 18490 18641 readonly args?: { ··· 18608 18759 readonly signal?: { 18609 18760 readonly __typename: 'Signal'; 18610 18761 readonly id: string; 18611 - readonly type: GQLSignalType; 18762 + readonly type: string; 18612 18763 readonly name: string; 18613 18764 readonly subcategory?: string | null; 18614 18765 readonly args?: { ··· 18684 18835 readonly signal?: { 18685 18836 readonly __typename: 'Signal'; 18686 18837 readonly id: string; 18687 - readonly type: GQLSignalType; 18838 + readonly type: string; 18688 18839 readonly name: string; 18689 18840 readonly subcategory?: string | null; 18690 18841 readonly args?: { ··· 18956 19107 readonly signal?: { 18957 19108 readonly __typename: 'Signal'; 18958 19109 readonly id: string; 18959 - readonly type: GQLSignalType; 19110 + readonly type: string; 18960 19111 readonly name: string; 18961 19112 readonly subcategory?: string | null; 18962 19113 readonly args?: { ··· 19033 19184 readonly signal?: { 19034 19185 readonly __typename: 'Signal'; 19035 19186 readonly id: string; 19036 - readonly type: GQLSignalType; 19187 + readonly type: string; 19037 19188 readonly name: string; 19038 19189 readonly subcategory?: string | null; 19039 19190 readonly args?: { ··· 19096 19247 readonly signalResults?: ReadonlyArray<{ 19097 19248 readonly __typename: 'SignalWithScore'; 19098 19249 readonly signalName: string; 19099 - readonly integration?: GQLIntegration | null; 19250 + readonly integration?: string | null; 19100 19251 readonly subcategory?: string | null; 19101 19252 readonly score: string; 19102 19253 }> | null; ··· 19150 19301 readonly signalResults?: ReadonlyArray<{ 19151 19302 readonly __typename: 'SignalWithScore'; 19152 19303 readonly signalName: string; 19153 - readonly integration?: GQLIntegration | null; 19304 + readonly integration?: string | null; 19154 19305 readonly subcategory?: string | null; 19155 19306 readonly score: string; 19156 19307 }> | null; ··· 19229 19380 readonly signalResults?: ReadonlyArray<{ 19230 19381 readonly __typename: 'SignalWithScore'; 19231 19382 readonly signalName: string; 19232 - readonly integration?: GQLIntegration | null; 19383 + readonly integration?: string | null; 19233 19384 readonly subcategory?: string | null; 19234 19385 readonly score: string; 19235 19386 }> | null; ··· 19309 19460 readonly signalResults?: ReadonlyArray<{ 19310 19461 readonly __typename: 'SignalWithScore'; 19311 19462 readonly signalName: string; 19312 - readonly integration?: GQLIntegration | null; 19463 + readonly integration?: string | null; 19313 19464 readonly subcategory?: string | null; 19314 19465 readonly score: string; 19315 19466 }> | null; ··· 19377 19528 readonly signal?: { 19378 19529 readonly __typename: 'Signal'; 19379 19530 readonly id: string; 19380 - readonly type: GQLSignalType; 19531 + readonly type: string; 19381 19532 readonly name: string; 19382 19533 readonly subcategory?: string | null; 19383 19534 readonly args?: { readonly __typename: 'AggregationSignalArgs' } | null; ··· 19441 19592 readonly signalResults?: ReadonlyArray<{ 19442 19593 readonly __typename: 'SignalWithScore'; 19443 19594 readonly signalName: string; 19444 - readonly integration?: GQLIntegration | null; 19595 + readonly integration?: string | null; 19445 19596 readonly subcategory?: string | null; 19446 19597 readonly score: string; 19447 19598 }> | null; ··· 19473 19624 readonly signal?: { 19474 19625 readonly __typename: 'Signal'; 19475 19626 readonly id: string; 19476 - readonly type: GQLSignalType; 19627 + readonly type: string; 19477 19628 readonly name: string; 19478 19629 readonly subcategory?: string | null; 19479 19630 readonly args?: { ··· 19550 19701 readonly signal?: { 19551 19702 readonly __typename: 'Signal'; 19552 19703 readonly id: string; 19553 - readonly type: GQLSignalType; 19704 + readonly type: string; 19554 19705 readonly name: string; 19555 19706 readonly subcategory?: string | null; 19556 19707 readonly args?: { ··· 19622 19773 readonly signals: ReadonlyArray<{ 19623 19774 readonly __typename: 'Signal'; 19624 19775 readonly id: string; 19625 - readonly integration?: GQLIntegration | null; 19776 + readonly integration?: string | null; 19626 19777 readonly eligibleSubcategories: ReadonlyArray<{ 19627 19778 readonly __typename: 'SignalSubcategory'; 19628 19779 readonly id: string; ··· 19657 19808 readonly signalResults?: ReadonlyArray<{ 19658 19809 readonly __typename: 'SignalWithScore'; 19659 19810 readonly signalName: string; 19660 - readonly integration?: GQLIntegration | null; 19811 + readonly integration?: string | null; 19661 19812 readonly subcategory?: string | null; 19662 19813 readonly score: string; 19663 19814 }> | null; ··· 19729 19880 readonly signalResults?: ReadonlyArray<{ 19730 19881 readonly __typename: 'SignalWithScore'; 19731 19882 readonly signalName: string; 19732 - readonly integration?: GQLIntegration | null; 19883 + readonly integration?: string | null; 19733 19884 readonly subcategory?: string | null; 19734 19885 readonly score: string; 19735 19886 }> | null; ··· 19764 19915 readonly signalResults?: ReadonlyArray<{ 19765 19916 readonly __typename: 'SignalWithScore'; 19766 19917 readonly signalName: string; 19767 - readonly integration?: GQLIntegration | null; 19918 + readonly integration?: string | null; 19768 19919 readonly subcategory?: string | null; 19769 19920 readonly score: string; 19770 19921 }> | null; ··· 19866 20017 readonly signalResults?: ReadonlyArray<{ 19867 20018 readonly __typename: 'SignalWithScore'; 19868 20019 readonly signalName: string; 19869 - readonly integration?: GQLIntegration | null; 20020 + readonly integration?: string | null; 19870 20021 readonly subcategory?: string | null; 19871 20022 readonly score: string; 19872 20023 }> | null; ··· 19920 20071 readonly signal?: { 19921 20072 readonly __typename: 'Signal'; 19922 20073 readonly id: string; 19923 - readonly type: GQLSignalType; 20074 + readonly type: string; 19924 20075 readonly name: string; 19925 20076 readonly subcategory?: string | null; 19926 20077 readonly args?: { ··· 19997 20148 readonly signal?: { 19998 20149 readonly __typename: 'Signal'; 19999 20150 readonly id: string; 20000 - readonly type: GQLSignalType; 20151 + readonly type: string; 20001 20152 readonly name: string; 20002 20153 readonly subcategory?: string | null; 20003 20154 readonly args?: { ··· 20060 20211 readonly signalResults?: ReadonlyArray<{ 20061 20212 readonly __typename: 'SignalWithScore'; 20062 20213 readonly signalName: string; 20063 - readonly integration?: GQLIntegration | null; 20214 + readonly integration?: string | null; 20064 20215 readonly subcategory?: string | null; 20065 20216 readonly score: string; 20066 20217 }> | null; ··· 20147 20298 readonly signal?: { 20148 20299 readonly __typename: 'Signal'; 20149 20300 readonly id: string; 20150 - readonly type: GQLSignalType; 20301 + readonly type: string; 20151 20302 readonly name: string; 20152 20303 readonly subcategory?: string | null; 20153 20304 readonly args?: { ··· 20223 20374 readonly signal?: { 20224 20375 readonly __typename: 'Signal'; 20225 20376 readonly id: string; 20226 - readonly type: GQLSignalType; 20377 + readonly type: string; 20227 20378 readonly name: string; 20228 20379 readonly subcategory?: string | null; 20229 20380 readonly args?: { ··· 20596 20747 readonly signal?: { 20597 20748 readonly __typename: 'Signal'; 20598 20749 readonly id: string; 20599 - readonly type: GQLSignalType; 20750 + readonly type: string; 20600 20751 readonly name: string; 20601 20752 readonly subcategory?: string | null; 20602 20753 readonly args?: { ··· 20672 20823 readonly signal?: { 20673 20824 readonly __typename: 'Signal'; 20674 20825 readonly id: string; 20675 - readonly type: GQLSignalType; 20826 + readonly type: string; 20676 20827 readonly name: string; 20677 20828 readonly subcategory?: string | null; 20678 20829 readonly args?: { ··· 21006 21157 readonly signals: ReadonlyArray<{ 21007 21158 readonly __typename: 'Signal'; 21008 21159 readonly id: string; 21009 - readonly type: GQLSignalType; 21160 + readonly type: string; 21010 21161 readonly name: string; 21011 - readonly integration?: GQLIntegration | null; 21162 + readonly integration?: string | null; 21163 + readonly integrationTitle?: string | null; 21164 + readonly integrationLogoUrl?: string | null; 21165 + readonly integrationLogoWithBackgroundUrl?: string | null; 21012 21166 readonly docsUrl?: string | null; 21013 21167 readonly description: string; 21014 21168 readonly eligibleInputs: ReadonlyArray<GQLSignalInputType>; ··· 21369 21523 export type GQLSignalsFragmentFragment = { 21370 21524 readonly __typename: 'Signal'; 21371 21525 readonly id: string; 21372 - readonly type: GQLSignalType; 21526 + readonly type: string; 21373 21527 readonly name: string; 21374 - readonly integration?: GQLIntegration | null; 21528 + readonly integration?: string | null; 21529 + readonly integrationTitle?: string | null; 21530 + readonly integrationLogoUrl?: string | null; 21531 + readonly integrationLogoWithBackgroundUrl?: string | null; 21375 21532 readonly docsUrl?: string | null; 21376 21533 readonly description: string; 21377 21534 readonly eligibleInputs: ReadonlyArray<GQLSignalInputType>; ··· 21449 21606 readonly signal?: { 21450 21607 readonly __typename: 'Signal'; 21451 21608 readonly id: string; 21452 - readonly type: GQLSignalType; 21609 + readonly type: string; 21453 21610 readonly name: string; 21454 21611 readonly subcategory?: string | null; 21455 21612 readonly args?: { readonly __typename: 'AggregationSignalArgs' } | null; ··· 21531 21688 readonly signal?: { 21532 21689 readonly __typename: 'Signal'; 21533 21690 readonly id: string; 21534 - readonly type: GQLSignalType; 21691 + readonly type: string; 21535 21692 readonly name: string; 21536 21693 readonly subcategory?: string | null; 21537 21694 readonly args?: { ··· 21607 21764 readonly signal?: { 21608 21765 readonly __typename: 'Signal'; 21609 21766 readonly id: string; 21610 - readonly type: GQLSignalType; 21767 + readonly type: string; 21611 21768 readonly name: string; 21612 21769 readonly subcategory?: string | null; 21613 21770 readonly args?: { ··· 21706 21863 readonly signal?: { 21707 21864 readonly __typename: 'Signal'; 21708 21865 readonly id: string; 21709 - readonly type: GQLSignalType; 21866 + readonly type: string; 21710 21867 readonly name: string; 21711 21868 readonly subcategory?: string | null; 21712 21869 readonly args?: { ··· 21782 21939 readonly signal?: { 21783 21940 readonly __typename: 'Signal'; 21784 21941 readonly id: string; 21785 - readonly type: GQLSignalType; 21942 + readonly type: string; 21786 21943 readonly name: string; 21787 21944 readonly subcategory?: string | null; 21788 21945 readonly args?: { ··· 21976 22133 readonly signal?: { 21977 22134 readonly __typename: 'Signal'; 21978 22135 readonly id: string; 21979 - readonly type: GQLSignalType; 22136 + readonly type: string; 21980 22137 readonly name: string; 21981 22138 readonly subcategory?: string | null; 21982 22139 readonly args?: { ··· 22052 22209 readonly signal?: { 22053 22210 readonly __typename: 'Signal'; 22054 22211 readonly id: string; 22055 - readonly type: GQLSignalType; 22212 + readonly type: string; 22056 22213 readonly name: string; 22057 22214 readonly subcategory?: string | null; 22058 22215 readonly args?: { ··· 22433 22590 readonly signal?: { 22434 22591 readonly __typename: 'Signal'; 22435 22592 readonly id: string; 22436 - readonly type: GQLSignalType; 22593 + readonly type: string; 22437 22594 readonly name: string; 22438 22595 readonly subcategory?: string | null; 22439 22596 readonly args?: { ··· 22509 22666 readonly signal?: { 22510 22667 readonly __typename: 'Signal'; 22511 22668 readonly id: string; 22512 - readonly type: GQLSignalType; 22669 + readonly type: string; 22513 22670 readonly name: string; 22514 22671 readonly subcategory?: string | null; 22515 22672 readonly args?: { ··· 22702 22859 readonly signal?: { 22703 22860 readonly __typename: 'Signal'; 22704 22861 readonly id: string; 22705 - readonly type: GQLSignalType; 22862 + readonly type: string; 22706 22863 readonly name: string; 22707 22864 readonly subcategory?: string | null; 22708 22865 readonly args?: { ··· 22778 22935 readonly signal?: { 22779 22936 readonly __typename: 'Signal'; 22780 22937 readonly id: string; 22781 - readonly type: GQLSignalType; 22938 + readonly type: string; 22782 22939 readonly name: string; 22783 22940 readonly subcategory?: string | null; 22784 22941 readonly args?: { ··· 23309 23466 readonly signals: ReadonlyArray<{ 23310 23467 readonly __typename: 'Signal'; 23311 23468 readonly id: string; 23312 - readonly type: GQLSignalType; 23469 + readonly type: string; 23313 23470 readonly name: string; 23314 - readonly integration?: GQLIntegration | null; 23471 + readonly integration?: string | null; 23472 + readonly integrationTitle?: string | null; 23473 + readonly integrationLogoUrl?: string | null; 23474 + readonly integrationLogoWithBackgroundUrl?: string | null; 23315 23475 readonly docsUrl?: string | null; 23316 23476 readonly description: string; 23317 23477 readonly eligibleInputs: ReadonlyArray<GQLSignalInputType>; ··· 24612 24772 type 24613 24773 name 24614 24774 integration 24775 + integrationTitle 24776 + integrationLogoUrl 24777 + integrationLogoWithBackgroundUrl 24615 24778 docsUrl 24616 24779 recommendedThresholds { 24617 24780 highPrecisionThreshold ··· 27362 27525 GQLSetIntegrationConfigMutation, 27363 27526 GQLSetIntegrationConfigMutationVariables 27364 27527 >; 27528 + export const GQLSetPluginIntegrationConfigDocument = gql` 27529 + mutation SetPluginIntegrationConfig( 27530 + $input: SetPluginIntegrationConfigInput! 27531 + ) { 27532 + setPluginIntegrationConfig(input: $input) { 27533 + ... on SetIntegrationConfigSuccessResponse { 27534 + config { 27535 + name 27536 + } 27537 + } 27538 + ... on IntegrationConfigTooManyCredentialsError { 27539 + title 27540 + } 27541 + ... on IntegrationNoInputCredentialsError { 27542 + title 27543 + } 27544 + ... on IntegrationEmptyInputCredentialsError { 27545 + title 27546 + } 27547 + } 27548 + } 27549 + `; 27550 + export type GQLSetPluginIntegrationConfigMutationFn = Apollo.MutationFunction< 27551 + GQLSetPluginIntegrationConfigMutation, 27552 + GQLSetPluginIntegrationConfigMutationVariables 27553 + >; 27554 + 27555 + /** 27556 + * __useGQLSetPluginIntegrationConfigMutation__ 27557 + * 27558 + * To run a mutation, you first call `useGQLSetPluginIntegrationConfigMutation` within a React component and pass it any options that fit your needs. 27559 + * When your component renders, `useGQLSetPluginIntegrationConfigMutation` returns a tuple that includes: 27560 + * - A mutate function that you can call at any time to execute the mutation 27561 + * - An object with fields that represent the current status of the mutation's execution 27562 + * 27563 + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; 27564 + * 27565 + * @example 27566 + * const [gqlSetPluginIntegrationConfigMutation, { data, loading, error }] = useGQLSetPluginIntegrationConfigMutation({ 27567 + * variables: { 27568 + * input: // value for 'input' 27569 + * }, 27570 + * }); 27571 + */ 27572 + export function useGQLSetPluginIntegrationConfigMutation( 27573 + baseOptions?: Apollo.MutationHookOptions< 27574 + GQLSetPluginIntegrationConfigMutation, 27575 + GQLSetPluginIntegrationConfigMutationVariables 27576 + >, 27577 + ) { 27578 + const options = { ...defaultOptions, ...baseOptions }; 27579 + return Apollo.useMutation< 27580 + GQLSetPluginIntegrationConfigMutation, 27581 + GQLSetPluginIntegrationConfigMutationVariables 27582 + >(GQLSetPluginIntegrationConfigDocument, options); 27583 + } 27584 + export type GQLSetPluginIntegrationConfigMutationHookResult = ReturnType< 27585 + typeof useGQLSetPluginIntegrationConfigMutation 27586 + >; 27587 + export type GQLSetPluginIntegrationConfigMutationResult = 27588 + Apollo.MutationResult<GQLSetPluginIntegrationConfigMutation>; 27589 + export type GQLSetPluginIntegrationConfigMutationOptions = 27590 + Apollo.BaseMutationOptions< 27591 + GQLSetPluginIntegrationConfigMutation, 27592 + GQLSetPluginIntegrationConfigMutationVariables 27593 + >; 27365 27594 export const GQLIntegrationConfigDocument = gql` 27366 - query IntegrationConfig($name: Integration!) { 27595 + query IntegrationConfig($name: String!) { 27367 27596 integrationConfig(name: $name) { 27368 27597 ... on IntegrationConfigSuccessResult { 27369 27598 config { 27370 27599 name 27600 + title 27601 + docsUrl 27602 + requiresConfig 27603 + logoUrl 27604 + logoWithBackgroundUrl 27605 + modelCard { 27606 + modelName 27607 + version 27608 + releaseDate 27609 + sections { 27610 + id 27611 + title 27612 + subsections { 27613 + title 27614 + fields { 27615 + label 27616 + value 27617 + } 27618 + } 27619 + fields { 27620 + label 27621 + value 27622 + } 27623 + } 27624 + } 27625 + modelCardLearnMoreUrl 27371 27626 apiCredential { 27372 27627 ... on GoogleContentSafetyApiIntegrationApiCredential { 27373 27628 apiKey ··· 27381 27636 id 27382 27637 label 27383 27638 } 27639 + } 27640 + ... on PluginIntegrationApiCredential { 27641 + credential 27384 27642 } 27385 27643 } 27386 27644 } ··· 27503 27761 export type GQLMyIntegrationsQueryResult = Apollo.QueryResult< 27504 27762 GQLMyIntegrationsQuery, 27505 27763 GQLMyIntegrationsQueryVariables 27764 + >; 27765 + export const GQLAvailableIntegrationsDocument = gql` 27766 + query AvailableIntegrations { 27767 + availableIntegrations { 27768 + name 27769 + title 27770 + docsUrl 27771 + requiresConfig 27772 + logoUrl 27773 + logoWithBackgroundUrl 27774 + } 27775 + } 27776 + `; 27777 + 27778 + /** 27779 + * __useGQLAvailableIntegrationsQuery__ 27780 + * 27781 + * To run a query within a React component, call `useGQLAvailableIntegrationsQuery` and pass it any options that fit your needs. 27782 + * When your component renders, `useGQLAvailableIntegrationsQuery` returns an object from Apollo Client that contains loading, error, and data properties 27783 + * you can use to render your UI. 27784 + * 27785 + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; 27786 + * 27787 + * @example 27788 + * const { data, loading, error } = useGQLAvailableIntegrationsQuery({ 27789 + * variables: { 27790 + * }, 27791 + * }); 27792 + */ 27793 + export function useGQLAvailableIntegrationsQuery( 27794 + baseOptions?: Apollo.QueryHookOptions< 27795 + GQLAvailableIntegrationsQuery, 27796 + GQLAvailableIntegrationsQueryVariables 27797 + >, 27798 + ) { 27799 + const options = { ...defaultOptions, ...baseOptions }; 27800 + return Apollo.useQuery< 27801 + GQLAvailableIntegrationsQuery, 27802 + GQLAvailableIntegrationsQueryVariables 27803 + >(GQLAvailableIntegrationsDocument, options); 27804 + } 27805 + export function useGQLAvailableIntegrationsLazyQuery( 27806 + baseOptions?: Apollo.LazyQueryHookOptions< 27807 + GQLAvailableIntegrationsQuery, 27808 + GQLAvailableIntegrationsQueryVariables 27809 + >, 27810 + ) { 27811 + const options = { ...defaultOptions, ...baseOptions }; 27812 + return Apollo.useLazyQuery< 27813 + GQLAvailableIntegrationsQuery, 27814 + GQLAvailableIntegrationsQueryVariables 27815 + >(GQLAvailableIntegrationsDocument, options); 27816 + } 27817 + export type GQLAvailableIntegrationsQueryHookResult = ReturnType< 27818 + typeof useGQLAvailableIntegrationsQuery 27819 + >; 27820 + export type GQLAvailableIntegrationsLazyQueryHookResult = ReturnType< 27821 + typeof useGQLAvailableIntegrationsLazyQuery 27822 + >; 27823 + export type GQLAvailableIntegrationsQueryResult = Apollo.QueryResult< 27824 + GQLAvailableIntegrationsQuery, 27825 + GQLAvailableIntegrationsQueryVariables 27506 27826 >; 27507 27827 export const GQLInvestigationItemTypesDocument = gql` 27508 27828 query InvestigationItemTypes { ··· 37176 37496 MatchingBankIds: 'MatchingBankIds', 37177 37497 IntegrationConfig: 'IntegrationConfig', 37178 37498 MyIntegrations: 'MyIntegrations', 37499 + AvailableIntegrations: 'AvailableIntegrations', 37179 37500 InvestigationItemTypes: 'InvestigationItemTypes', 37180 37501 GetOrgData: 'GetOrgData', 37181 37502 GetItemsWithId: 'GetItemsWithId', ··· 37297 37618 DeleteTextBank: 'DeleteTextBank', 37298 37619 BulkActionExecution: 'BulkActionExecution', 37299 37620 SetIntegrationConfig: 'SetIntegrationConfig', 37621 + SetPluginIntegrationConfig: 'SetPluginIntegrationConfig', 37300 37622 DeleteItemType: 'DeleteItemType', 37301 37623 CreateContentType: 'CreateContentType', 37302 37624 UpdateContentType: 'UpdateContentType',
+11 -7
client/src/models/signal.ts
··· 26 26 | 'eligibleInputs' 27 27 | 'subcategory' 28 28 | 'integration' 29 + | 'integrationTitle' 30 + | 'integrationLogoUrl' 31 + | 'integrationLogoWithBackgroundUrl' 29 32 | 'pricingStructure' 30 33 | 'docsUrl' 31 34 | 'recommendedThresholds' ··· 34 37 | 'allowedInAutomatedRules' 35 38 >; 36 39 37 - export function receivesRegexInput(type: GQLSignalType) { 40 + /** Signal type is string to support plugin signal types (e.g. RANDOM_SIGNAL_SELECTION). */ 41 + export function receivesRegexInput(type: string) { 38 42 return ( 39 43 type === GQLSignalType.TextMatchingContainsRegex || 40 44 type === GQLSignalType.TextMatchingNotContainsRegex ··· 42 46 } 43 47 44 48 /** 45 - * This function returns the integration type for a given signal type 46 - * @param type Signal type to find the integration for 47 - * @returns a GQLIntegration enum value, or null in the case of signals that are 48 - * not integrations 49 + * Returns the integration type for a given signal type. 50 + * @param type Signal type (built-in or plugin) 51 + * @returns a GQLIntegration enum value, or null for non-integration signals or plugin signals 49 52 */ 50 - export function integrationForSignalType(type: GQLSignalType) { 53 + export function integrationForSignalType(type: string) { 51 54 switch (type) { 52 55 case 'GOOGLE_CONTENT_SAFETY_API_IMAGE': 53 56 return GQLIntegration.GoogleContentSafetyApi; ··· 80 83 case 'BENIGN_MODEL': 81 84 return null; 82 85 default: 83 - assertUnreachable(type); 86 + // Plugin signal types (e.g. RANDOM_SIGNAL_SELECTION) or unknown: no built-in integration 87 + return null; 84 88 } 85 89 } 86 90
+2 -2
client/src/utils/signalUtils.ts
··· 1 1 import { SignalSubcategory } from '@roostorg/types'; 2 2 import transform from 'lodash/transform'; 3 3 4 - import { GQLIntegration, GQLSignalSubcategory } from '../graphql/generated'; 4 + import { GQLSignalSubcategory } from '../graphql/generated'; 5 5 import { safePick } from './misc'; 6 6 7 7 /** ··· 39 39 40 40 export function createSubcategoryIdToLabelMapping( 41 41 signals: readonly { 42 - integration?: GQLIntegration | null; 42 + integration?: string | null; 43 43 eligibleSubcategories: readonly { id: string; label: string }[]; 44 44 }[], 45 45 ) {
+24 -9
client/src/webpages/dashboard/integrations/IntegrationCard.tsx
··· 1 1 import { ReactNode, useState } from 'react'; 2 2 import { Link, useNavigate } from 'react-router-dom'; 3 3 4 + import type { GQLIntegrationMetadata } from '../../../graphql/generated'; 4 5 import CoopModal from '../components/CoopModal'; 5 6 6 - import { IntegrationConfig } from './IntegrationsDashboard'; 7 + import { INTEGRATION_LOGO_FALLBACKS } from './integrationLogos'; 7 8 8 9 export default function IntegrationCard(props: { 9 - integration: IntegrationConfig; 10 + integration: GQLIntegrationMetadata; 10 11 useExternalURL?: boolean; 11 12 }) { 12 13 const { integration, useExternalURL } = props; 13 - const { name, title, logo, requiresInfo, url } = integration; 14 + const { name, title, docsUrl } = integration; 15 + // Integrations page uses only the plain logo (logoUrl from logoPath). Do not fall back to logoWithBackgroundUrl. 16 + const rawLogo = 17 + integration.logoUrl ?? 18 + INTEGRATION_LOGO_FALLBACKS[name]?.logo ?? 19 + ''; 20 + // Resolve relative API paths to absolute URL so img loads correctly (e.g. /api/v1/integration-logos/ID). 21 + const logo = 22 + typeof rawLogo === 'string' && rawLogo.startsWith('/') 23 + ? `${window.location.origin}${rawLogo}` 24 + : rawLogo; 14 25 const navigate = useNavigate(); 15 26 16 27 const [modalVisible, setModalVisible] = useState(false); ··· 31 42 ]} 32 43 > 33 44 <div className="flex flex-col items-center max-w-md"> 34 - <div className="p-6 mb-6 rounded-full bg-slate-200 w-fit h-fit"> 35 - <img src={logo} alt="Logo" className="w-16 h-16" /> 45 + <div className="p-6 mb-6 rounded-full bg-slate-200 w-fit h-fit flex items-center justify-center"> 46 + {logo ? ( 47 + <img src={logo} alt="" className="w-16 h-16 object-contain" /> 48 + ) : null} 36 49 </div> 37 50 <div> 38 51 <span className="font-medium">{title}</span> doesn't require any ··· 56 69 }) => { 57 70 if (Boolean(useExternalURL)) { 58 71 return ( 59 - <a href={url} {...rest}> 72 + <a href={docsUrl} {...rest}> 60 73 {children} 61 74 </a> 62 75 ); 63 76 } 64 - if (!requiresInfo) { 77 + if (!integration.requiresConfig) { 65 78 return ( 66 79 <div onClick={() => setModalVisible(true)} {...rest}> 67 80 {children} ··· 81 94 return ( 82 95 <> 83 96 <Wrapper className="relative flex flex-col items-center justify-center w-full h-full p-6 pt-12 pb-12 bg-white border border-solid rounded-3xl border-slate-300 transition-all duration-200 ease-out box-border hover:transform hover:-translate-y-1 hover:transition-all hover:duration-200 hover:ease-in hover:dashboard-border-primary/70 hover:cursor-pointer"> 84 - <div className="w-16 h-16 p-4 mb-6 rounded-full bg-slate-200"> 85 - <img src={logo} alt="Logo" className="w-full h-full" /> 97 + <div className="w-16 h-16 p-4 mb-6 rounded-full bg-slate-200 flex items-center justify-center overflow-hidden"> 98 + {logo ? ( 99 + <img src={logo} alt="" className="w-full h-full object-contain" /> 100 + ) : null} 86 101 </div> 87 102 <div className="flex flex-col justify-start text-lg font-bold text-center text-slate-700"> 88 103 {title}
+41 -2
client/src/webpages/dashboard/integrations/IntegrationConfigApiCredentialsSection.tsx
··· 3 3 4 4 import { 5 5 GQLGoogleContentSafetyApiIntegrationApiCredential, 6 - GQLIntegration, 7 6 GQLIntegrationApiCredential, 8 7 GQLOpenAiIntegrationApiCredential, 9 8 GQLZentropiIntegrationApiCredential, 10 9 } from '../../../graphql/generated'; 11 10 12 11 export default function IntegrationConfigApiCredentialsSection(props: { 13 - name: GQLIntegration; 12 + name: string; 14 13 setApiCredential: (cred: GQLIntegrationApiCredential) => void; 15 14 apiCredential: GQLIntegrationApiCredential; 16 15 }) { ··· 142 141 ); 143 142 }; 144 143 144 + const PLUGIN_FIELD_LABELS: Record<string, string> = { 145 + truePercentage: 'True percentage (0–100)', 146 + }; 147 + 148 + const renderPluginCredential = ( 149 + pluginCredential: { __typename: 'PluginIntegrationApiCredential'; credential: Record<string, unknown> }, 150 + ) => { 151 + const credential = pluginCredential.credential ?? {}; 152 + const entries = Object.entries(credential).filter( 153 + ([key]) => key !== 'name', 154 + ); 155 + const fieldsToShow = 156 + entries.length > 0 157 + ? entries 158 + : [['truePercentage', ''] as [string, unknown]]; 159 + return ( 160 + <div className="flex flex-col gap-4"> 161 + {fieldsToShow.map(([key, value]) => ( 162 + <div key={key} className="flex flex-col w-1/2"> 163 + <div className="mb-1"> 164 + {PLUGIN_FIELD_LABELS[key] ?? key} 165 + </div> 166 + <Input 167 + value={String(value ?? '')} 168 + onChange={(event) => { 169 + const next = { ...credential, [key]: event.target.value }; 170 + setApiCredential({ 171 + __typename: 'PluginIntegrationApiCredential', 172 + credential: next as import('../../../graphql/generated').Scalars['JSONObject'], 173 + }); 174 + }} 175 + /> 176 + </div> 177 + ))} 178 + </div> 179 + ); 180 + }; 181 + 145 182 const projectKeysInput = () => { 146 183 switch (apiCredential.__typename) { 147 184 case 'GoogleContentSafetyApiIntegrationApiCredential': ··· 150 187 return renderOpenAiCredential(apiCredential); 151 188 case 'ZentropiIntegrationApiCredential': 152 189 return renderZentropiCredential(apiCredential); 190 + case 'PluginIntegrationApiCredential': 191 + return renderPluginCredential(apiCredential); 153 192 default: 154 193 throw new Error('Integration not implemented yet'); 155 194 }
+196 -45
client/src/webpages/dashboard/integrations/IntegrationConfigForm.tsx
··· 1 1 import { gql } from '@apollo/client'; 2 - import { useMemo, useState } from 'react'; 2 + import { useEffect, useState } from 'react'; 3 3 import { Helmet } from 'react-helmet-async'; 4 4 import { useNavigate, useParams } from 'react-router-dom'; 5 5 ··· 8 8 import CoopModal from '../components/CoopModal'; 9 9 10 10 import { 11 - GQLIntegration, 12 11 GQLIntegrationApiCredential, 13 12 GQLIntegrationConfigDocument, 14 13 GQLUserPermission, ··· 16 15 useGQLIntegrationConfigQuery, 17 16 useGQLPermissionGatedRouteLoggedInUserQuery, 18 17 useGQLSetIntegrationConfigMutation, 18 + useGQLSetPluginIntegrationConfigMutation, 19 19 type GQLGoogleContentSafetyApiIntegrationApiCredential, 20 20 type GQLOpenAiIntegrationApiCredential, 21 21 type GQLZentropiIntegrationApiCredential, ··· 26 26 } from '../../../graphql/inputHelpers'; 27 27 import { userHasPermissions } from '../../../routing/permissions'; 28 28 import IntegrationConfigApiCredentialsSection from './IntegrationConfigApiCredentialsSection'; 29 - import { INTEGRATION_CONFIGS } from './integrationConfigs'; 29 + import { INTEGRATION_LOGO_FALLBACKS } from './integrationLogos'; 30 + import ModelCardView from './ModelCardView'; 30 31 31 32 gql` 32 33 mutation SetIntegrationConfig($input: SetIntegrationConfigInput!) { ··· 48 49 } 49 50 } 50 51 51 - query IntegrationConfig($name: Integration!) { 52 + mutation SetPluginIntegrationConfig($input: SetPluginIntegrationConfigInput!) { 53 + setPluginIntegrationConfig(input: $input) { 54 + ... on SetIntegrationConfigSuccessResponse { 55 + config { 56 + name 57 + } 58 + } 59 + ... on IntegrationConfigTooManyCredentialsError { 60 + title 61 + } 62 + ... on IntegrationNoInputCredentialsError { 63 + title 64 + } 65 + ... on IntegrationEmptyInputCredentialsError { 66 + title 67 + } 68 + } 69 + } 70 + 71 + query IntegrationConfig($name: String!) { 52 72 integrationConfig(name: $name) { 53 73 ... on IntegrationConfigSuccessResult { 54 74 config { 55 75 name 76 + title 77 + docsUrl 78 + requiresConfig 79 + logoUrl 80 + logoWithBackgroundUrl 81 + modelCard { 82 + modelName 83 + version 84 + releaseDate 85 + sections { 86 + id 87 + title 88 + subsections { 89 + title 90 + fields { 91 + label 92 + value 93 + } 94 + } 95 + fields { 96 + label 97 + value 98 + } 99 + } 100 + } 101 + modelCardLearnMoreUrl 56 102 apiCredential { 57 103 ... on GoogleContentSafetyApiIntegrationApiCredential { 58 104 apiKey ··· 67 113 label 68 114 } 69 115 } 116 + ... on PluginIntegrationApiCredential { 117 + credential 118 + } 70 119 } 71 120 } 72 121 } ··· 88 137 * IntegrationConfigApiCredential), so the UI can display the proper empty inputs. 89 138 */ 90 139 export function getNewEmptyApiKey( 91 - name: GQLIntegration, 140 + name: string, 92 141 ): GQLIntegrationApiCredential { 93 142 switch (name) { 94 143 case 'GOOGLE_CONTENT_SAFETY_API': { ··· 108 157 }; 109 158 } 110 159 default: { 111 - throw new Error(`${name} integration not implemented.`); 160 + return { 161 + __typename: 'PluginIntegrationApiCredential', 162 + credential: {}, 163 + }; 112 164 } 113 165 } 114 166 } ··· 119 171 throw Error('Integration name not provided'); 120 172 } 121 173 // Cast back to upper case (see lowercase cast in IntegrationCard.tsx) 122 - const integrationName = name.toUpperCase() as GQLIntegration; 123 - const config = INTEGRATION_CONFIGS.find((i) => i.name === integrationName); 124 - if (config == null) { 125 - throw Error(`Integration with name ${name} not found`); 126 - } 127 - const formattedName = config.title; 174 + const integrationName = name.toUpperCase(); 128 175 const navigate = useNavigate(); 129 176 130 177 const [modalVisible, setModalVisible] = useState(false); ··· 142 189 }, 143 190 onCompleted: () => showModal(), 144 191 }); 145 - const mutationError = setConfigMutationParams.error; 146 - const mutationLoading = setConfigMutationParams.loading; 192 + const [setPluginConfig, setPluginConfigMutationParams] = 193 + useGQLSetPluginIntegrationConfigMutation({ 194 + onError: () => { 195 + showModal(); 196 + }, 197 + onCompleted: () => showModal(), 198 + }); 199 + const mutationError = 200 + setConfigMutationParams.error ?? setPluginConfigMutationParams.error; 201 + const mutationLoading = 202 + setConfigMutationParams.loading || setPluginConfigMutationParams.loading; 147 203 148 204 const { 149 205 loading, ··· 177 233 * If editing an existing config and the INTEGRATION_CONFIG_QUERY 178 234 * has finished, reset the state values to whatever the query returned 179 235 */ 180 - useMemo(() => { 236 + useEffect(() => { 181 237 if (response?.config != null) { 182 - setApiCredential(response.config.apiCredential); 238 + const cred = response.config.apiCredential; 239 + if (cred.__typename === 'PluginIntegrationApiCredential') { 240 + setApiCredential({ 241 + __typename: 'PluginIntegrationApiCredential', 242 + credential: cred.credential ?? {}, 243 + }); 244 + } else { 245 + setApiCredential(cred); 246 + } 183 247 } 184 248 }, [response]); 185 249 ··· 189 253 if (loading || userQueryLoading) { 190 254 return <FullScreenLoading />; 191 255 } 256 + 257 + const apiConfig = 258 + response?.__typename === 'IntegrationConfigSuccessResult' 259 + ? response.config 260 + : undefined; 261 + const formattedName = 262 + apiConfig?.title ?? integrationName.replace(/_/g, ' '); 263 + const logo = apiConfig 264 + ? (apiConfig.logoUrl ?? 265 + INTEGRATION_LOGO_FALLBACKS[apiConfig.name]?.logo ?? 266 + '') 267 + : ''; 268 + 192 269 const canEditConfig = userHasPermissions(permissions, [ 193 270 GQLUserPermission.ManageOrg, 194 271 ]); ··· 197 274 GoogleContentSafetyApiIntegrationApiCredential: 'googleContentSafetyApi', 198 275 OpenAiIntegrationApiCredential: 'openAi', 199 276 ZentropiIntegrationApiCredential: 'zentropi', 277 + PluginIntegrationApiCredential: 'pluginCredential', 200 278 }); 201 279 280 + const isPluginIntegration = ![ 281 + 'GOOGLE_CONTENT_SAFETY_API', 282 + 'OPEN_AI', 283 + 'ZENTROPI', 284 + ].includes(integrationName); 202 285 const validationMessage = (() => { 286 + if (isPluginIntegration) { 287 + return undefined; 288 + } 203 289 if ( 204 290 'googleContentSafetyApi' in mappedApiCredential && 205 291 !( ··· 237 323 <CoopButton 238 324 title="Save" 239 325 loading={mutationLoading} 240 - onClick={async () => 241 - setConfig({ 242 - variables: { 243 - input: { 244 - apiCredential: stripTypename(mappedApiCredential), 245 - }, 326 + onClick={async () => { 327 + const refetchQueries = [ 328 + namedOperations.Query.MyIntegrations, 329 + { 330 + query: GQLIntegrationConfigDocument, 331 + variables: { name: integrationName }, 246 332 }, 247 - refetchQueries: [ 248 - namedOperations.Query.MyIntegrations, 249 - { 250 - query: GQLIntegrationConfigDocument, 251 - variables: { name: integrationName }, 333 + ]; 334 + if (isPluginIntegration) { 335 + const cred = 336 + apiCredential.__typename === 'PluginIntegrationApiCredential' 337 + ? apiCredential.credential ?? {} 338 + : {}; 339 + await setPluginConfig({ 340 + variables: { 341 + input: { integrationId: integrationName, credential: cred }, 252 342 }, 253 - ], 254 - }) 255 - } 343 + refetchQueries, 344 + }); 345 + } else { 346 + await setConfig({ 347 + variables: { 348 + input: { 349 + apiCredential: stripTypename(mappedApiCredential), 350 + }, 351 + }, 352 + refetchQueries, 353 + }); 354 + } 355 + }} 256 356 disabled={!canEditConfig || validationMessage != null} 257 357 disabledTooltipTitle={validationMessage} 258 358 /> ··· 296 396 ); 297 397 298 398 const headerSubtitle = ( 299 - integration: GQLIntegration, 399 + integrationName: string, 300 400 formattedName: string, 301 401 ): React.ReactNode | string | undefined => { 302 - switch (integration) { 303 - case GQLIntegration.GoogleContentSafetyApi: 402 + switch (integrationName) { 403 + case 'GOOGLE_CONTENT_SAFETY_API': 304 404 return ( 305 405 <> 306 406 The Content Safety API is an AI classifier which issues a Child ··· 322 422 back in touch shortly to take the application forward if you qualify. 323 423 </> 324 424 ); 325 - case GQLIntegration.OpenAi: 425 + case 'OPEN_AI': 326 426 return `The ${formattedName} integration requires one API Key.`; 327 427 default: 328 428 return undefined; 329 429 } 330 430 }; 331 431 432 + const apiModelCard = response?.config?.modelCard; 433 + const apiModelCardLearnMoreUrl = response?.config?.modelCardLearnMoreUrl; 434 + const hasModelCard = apiModelCard != null; 435 + 332 436 return ( 333 437 <div className="flex flex-col text-start"> 334 438 <Helmet> 335 439 <title>{formattedName} Integration</title> 336 440 </Helmet> 337 441 <div className="flex flex-col justify-between w-4/5 mb-4"> 338 - <div className="mb-1 text-2xl font-bold">{`${formattedName} Integration`}</div> 339 - <div className="mb-4 text-base text-zinc-900"> 340 - {headerSubtitle(integrationName, formattedName)} 442 + <div className="flex items-center gap-3 mb-1"> 443 + <div className="w-12 h-12 rounded-full bg-slate-200 flex items-center justify-center shrink-0 overflow-hidden"> 444 + <img 445 + src={logo} 446 + alt="" 447 + className="w-full h-full object-contain" 448 + /> 449 + </div> 450 + <div className="text-2xl font-bold">{`${formattedName} Integration`}</div> 341 451 </div> 452 + {!hasModelCard && ( 453 + <div className="mb-4 text-base text-zinc-900"> 454 + {headerSubtitle(integrationName, formattedName)} 455 + </div> 456 + )} 342 457 </div> 343 - <IntegrationConfigApiCredentialsSection 344 - name={integrationName} 345 - apiCredential={apiCredential} 346 - setApiCredential={(cred: GQLIntegrationApiCredential) => 347 - setApiCredential(cred) 348 - } 349 - /> 350 - {saveButton} 458 + 459 + {hasModelCard && apiModelCard ? ( 460 + <div className="flex flex-col lg:flex-row gap-8 w-full max-w-5xl"> 461 + <div className="flex-1 min-w-0"> 462 + <ModelCardView card={apiModelCard} /> 463 + </div> 464 + <div className="flex flex-col lg:w-80 shrink-0"> 465 + {apiModelCardLearnMoreUrl != null && ( 466 + <a 467 + href={apiModelCardLearnMoreUrl} 468 + target="_blank" 469 + rel="noopener noreferrer" 470 + className="text-sm text-blue-600 hover:underline mb-4 inline-flex items-center gap-1" 471 + > 472 + <span className="align-middle">ⓘ</span> 473 + <span>Learn more about how to read model cards</span> 474 + </a> 475 + )} 476 + <div className="font-semibold text-zinc-800 mb-2">Credentials</div> 477 + <div className="text-sm text-zinc-600 mb-3"> 478 + Configure your credentials below. 479 + </div> 480 + <IntegrationConfigApiCredentialsSection 481 + name={integrationName} 482 + apiCredential={apiCredential} 483 + setApiCredential={(cred: GQLIntegrationApiCredential) => 484 + setApiCredential(cred) 485 + } 486 + /> 487 + {saveButton} 488 + </div> 489 + </div> 490 + ) : ( 491 + <> 492 + <IntegrationConfigApiCredentialsSection 493 + name={integrationName} 494 + apiCredential={apiCredential} 495 + setApiCredential={(cred: GQLIntegrationApiCredential) => 496 + setApiCredential(cred) 497 + } 498 + /> 499 + {saveButton} 500 + </> 501 + )} 351 502 {modal} 352 503 </div> 353 504 );
+29 -19
client/src/webpages/dashboard/integrations/IntegrationsDashboard.tsx
··· 5 5 import DashboardHeader from '../components/DashboardHeader'; 6 6 7 7 import { 8 - GQLIntegration, 8 + useGQLAvailableIntegrationsQuery, 9 9 useGQLMyIntegrationsQuery, 10 10 } from '../../../graphql/generated'; 11 11 import IntegrationCard from './IntegrationCard'; 12 - import { INTEGRATION_CONFIGS } from './integrationConfigs'; 13 - 14 - export type IntegrationConfig = { 15 - name: GQLIntegration; 16 - title: string; 17 - logo: string; 18 - logoWithBackground: string; 19 - url: string; 20 - requiresInfo: boolean; 21 - }; 22 12 23 13 export default function IntegrationsDashboard() { 24 14 gql` ··· 31 21 } 32 22 `; 33 23 34 - const { loading, error, data } = useGQLMyIntegrationsQuery(); 24 + gql` 25 + query AvailableIntegrations { 26 + availableIntegrations { 27 + name 28 + title 29 + docsUrl 30 + requiresConfig 31 + logoUrl 32 + logoWithBackgroundUrl 33 + } 34 + } 35 + `; 36 + 37 + const { loading: loadingCatalog, data: catalogData } = 38 + useGQLAvailableIntegrationsQuery({ 39 + fetchPolicy: 'network-only', 40 + }); 41 + const { loading: loadingMy, error, data: myData } = 42 + useGQLMyIntegrationsQuery(); 43 + 44 + const loading = loadingCatalog || loadingMy; 35 45 36 46 if (loading) { 37 47 return <FullScreenLoading />; ··· 41 51 throw error; 42 52 } 43 53 44 - const integrationNames = 45 - data?.myOrg?.integrationConfigs?.map((config) => config.name) ?? []; 54 + const allIntegrations = catalogData?.availableIntegrations ?? []; 55 + const myIntegrationNames = 56 + myData?.myOrg?.integrationConfigs?.map((config) => config.name) ?? []; 46 57 47 - const myIntegrations = INTEGRATION_CONFIGS.filter((it) => 48 - integrationNames.includes(it.name), 58 + const myIntegrations = allIntegrations.filter((it) => 59 + myIntegrationNames.includes(it.name), 49 60 ); 50 - 51 - const otherIntegrations = INTEGRATION_CONFIGS.filter( 52 - (it) => !myIntegrations.includes(it), 61 + const otherIntegrations = allIntegrations.filter( 62 + (it) => !myIntegrationNames.includes(it.name), 53 63 ).sort((a, b) => a.name.localeCompare(b.name)); 54 64 55 65 return (
+127
client/src/webpages/dashboard/integrations/ModelCardView.tsx
··· 1 + import { useState } from 'react'; 2 + import { ChevronDown, ChevronRight } from 'lucide-react'; 3 + 4 + import type { 5 + GQLModelCard, 6 + GQLModelCardField, 7 + GQLModelCardSection, 8 + GQLModelCardSubsection, 9 + } from '../../../graphql/generated'; 10 + 11 + type ModelCardViewProps = { card: GQLModelCard }; 12 + 13 + /** 14 + * Renders a single label-value row. Linkifies URLs in value. 15 + */ 16 + function ModelCardFieldRow({ field }: { field: GQLModelCardField }) { 17 + const isUrl = 18 + field.value.startsWith('http://') || field.value.startsWith('https://'); 19 + return ( 20 + <div className="flex flex-col gap-0.5 py-1 text-sm"> 21 + <span className="font-medium text-zinc-600">{field.label}</span> 22 + {isUrl ? ( 23 + <a 24 + href={field.value} 25 + target="_blank" 26 + rel="noopener noreferrer" 27 + className="text-blue-600 hover:underline break-all" 28 + > 29 + {field.value} 30 + </a> 31 + ) : ( 32 + <span className="text-zinc-900">{field.value}</span> 33 + )} 34 + </div> 35 + ); 36 + } 37 + 38 + function SubsectionBlock({ 39 + subsection, 40 + }: { 41 + subsection: GQLModelCardSubsection; 42 + }) { 43 + return ( 44 + <div className="mb-4 last:mb-0"> 45 + <div className="mb-2 font-semibold text-zinc-800">{subsection.title}</div> 46 + <div className="flex flex-col gap-0"> 47 + {subsection.fields.map((field, i) => ( 48 + <ModelCardFieldRow key={i} field={field} /> 49 + ))} 50 + </div> 51 + </div> 52 + ); 53 + } 54 + 55 + function ModelCardSectionBlock({ 56 + section, 57 + defaultOpen = true, 58 + }: { 59 + section: GQLModelCardSection; 60 + defaultOpen?: boolean; 61 + }) { 62 + const [open, setOpen] = useState(defaultOpen); 63 + const hasSubsections = section.subsections && section.subsections.length > 0; 64 + const hasFields = section.fields && section.fields.length > 0; 65 + const hasContent = hasSubsections ?? hasFields; 66 + 67 + return ( 68 + <div className="border-b border-zinc-200 last:border-b-0"> 69 + <button 70 + type="button" 71 + onClick={() => setOpen((o) => !o)} 72 + className="flex w-full items-center justify-between py-3 text-left font-semibold text-zinc-900 hover:bg-zinc-50 rounded" 73 + aria-expanded={open} 74 + > 75 + {section.title} 76 + {hasContent ? ( 77 + open ? ( 78 + <ChevronDown className="shrink-0 text-zinc-500" size={18} /> 79 + ) : ( 80 + <ChevronRight className="shrink-0 text-zinc-500" size={18} /> 81 + ) 82 + ) : null} 83 + </button> 84 + {open && hasContent && ( 85 + <div className="pb-4 pl-0"> 86 + {hasSubsections && 87 + section.subsections?.map((sub) => ( 88 + <SubsectionBlock key={sub.title} subsection={sub} /> 89 + ))} 90 + {hasFields && !hasSubsections && ( 91 + <div className="flex flex-col gap-0"> 92 + {section.fields?.map((field, i) => ( 93 + <ModelCardFieldRow key={i} field={field} /> 94 + ))} 95 + </div> 96 + )} 97 + </div> 98 + )} 99 + </div> 100 + ); 101 + } 102 + 103 + export default function ModelCardView({ card }: ModelCardViewProps) { 104 + const sections = card.sections ?? []; 105 + return ( 106 + <div className="flex flex-col"> 107 + <div className="mb-3 flex flex-wrap items-baseline gap-x-3 gap-y-1"> 108 + <span className="text-lg font-semibold text-zinc-900"> 109 + {card.modelName} 110 + </span> 111 + <span className="text-sm text-zinc-600">{card.version}</span> 112 + {card.releaseDate != null && ( 113 + <span className="text-sm text-zinc-500">{card.releaseDate}</span> 114 + )} 115 + </div> 116 + <div className="flex flex-col"> 117 + {sections.map((section) => ( 118 + <ModelCardSectionBlock 119 + key={section.id} 120 + section={section} 121 + defaultOpen={sections.length <= 3} 122 + /> 123 + ))} 124 + </div> 125 + </div> 126 + ); 127 + }
+18 -5
client/src/webpages/dashboard/integrations/integrationConfigs.ts
··· 1 - import { GQLIntegration } from '../../../graphql/generated'; 1 + /** 2 + * Client-side integration list for contexts that do not yet use the API 3 + * (e.g. rule form signal modals). Prefer backend-driven data (availableIntegrations, 4 + * integrationConfig) for the integrations dashboard and detail page. 5 + */ 6 + import type { GQLIntegration } from '../../../graphql/generated'; 2 7 import GoogleLogo from '../../../images/GoogleLogo.png'; 3 8 import GoogleLogoWithBackground from '../../../images/GoogleLogoWithBackground.png'; 4 9 import OpenAILogo from '../../../images/OpenAILogo.png'; 5 10 import OpenAILogoWithBackground from '../../../images/OpenAILogoWithBackground.png'; 6 11 import ZentropiLogo from '../../../images/ZentropiLogo.png'; 7 - import { IntegrationConfig } from './IntegrationsDashboard'; 12 + 13 + export type IntegrationConfig = { 14 + name: GQLIntegration; 15 + title: string; 16 + logo: string; 17 + logoWithBackground: string; 18 + url: string; 19 + requiresInfo: boolean; 20 + }; 8 21 9 22 export const INTEGRATION_CONFIGS: IntegrationConfig[] = [ 10 23 { 11 - name: GQLIntegration.GoogleContentSafetyApi, 24 + name: 'GOOGLE_CONTENT_SAFETY_API' as GQLIntegration, 12 25 title: 'Google Content Safety API', 13 26 logo: GoogleLogo, 14 27 logoWithBackground: GoogleLogoWithBackground, ··· 16 29 requiresInfo: true, 17 30 }, 18 31 { 19 - name: GQLIntegration.OpenAi, 32 + name: 'OPEN_AI' as GQLIntegration, 20 33 title: 'OpenAI', 21 34 logo: OpenAILogo, 22 35 logoWithBackground: OpenAILogoWithBackground, ··· 24 37 requiresInfo: true, 25 38 }, 26 39 { 27 - name: GQLIntegration.Zentropi, 40 + name: 'ZENTROPI' as GQLIntegration, 28 41 title: 'Zentropi', 29 42 logo: ZentropiLogo, 30 43 logoWithBackground: ZentropiLogo,
+27
client/src/webpages/dashboard/integrations/integrationLogos.ts
··· 1 + /** 2 + * Fallback logo assets when the backend does not provide logoUrl. 3 + * Integrations are backend-driven; logos can come from API (logoUrl) or this map. 4 + * Keys are integration names (built-in enum or plugin id string). 5 + */ 6 + import GoogleLogo from '../../../images/GoogleLogo.png'; 7 + import GoogleLogoWithBackground from '../../../images/GoogleLogoWithBackground.png'; 8 + import OpenAILogo from '../../../images/OpenAILogo.png'; 9 + import OpenAILogoWithBackground from '../../../images/OpenAILogoWithBackground.png'; 10 + import ZentropiLogo from '../../../images/ZentropiLogo.png'; 11 + 12 + export const INTEGRATION_LOGO_FALLBACKS: Partial< 13 + Record<string, { logo: string; logoWithBackground: string }> 14 + > = { 15 + GOOGLE_CONTENT_SAFETY_API: { 16 + logo: GoogleLogo, 17 + logoWithBackground: GoogleLogoWithBackground, 18 + }, 19 + OPEN_AI: { 20 + logo: OpenAILogo, 21 + logoWithBackground: OpenAILogoWithBackground, 22 + }, 23 + ZENTROPI: { 24 + logo: ZentropiLogo, 25 + logoWithBackground: ZentropiLogo, 26 + }, 27 + };
+1 -2
client/src/webpages/dashboard/rules/info/insights/RuleInsightsSamplesTable.tsx
··· 29 29 import { 30 30 GQLField, 31 31 GQLFieldType, 32 - GQLIntegration, 33 32 GQLRuleStatus, 34 33 useGQLRuleInsightsCurrentVersionSamplesQuery, 35 34 useGQLRuleInsightsPriorVersionSamplesLazyQuery, ··· 62 61 export type SignalWithResult = { 63 62 subcategory?: string | null; 64 63 signalName: string; 65 - integration?: GQLIntegration | null | undefined; 64 + integration?: string | null | undefined; 66 65 score?: string; 67 66 }; 68 67
+3 -2
client/src/webpages/dashboard/rules/info/insights/sample_details/RuleInsightsSampleDetailMatchingValues.tsx
··· 100 100 matchingValues.strings!, 101 101 result?.outcome, 102 102 result?.matchedValue ?? undefined, 103 - (condition.signal?.type && receivesRegexInput(condition.signal.type)) ?? 104 - false, 103 + Boolean( 104 + condition.signal?.type && receivesRegexInput(condition.signal.type), 105 + ), 105 106 ); 106 107 case MatchingValueType.LOCATION: 107 108 return renderMatchingValuesStringsInput(
+3
client/src/webpages/dashboard/rules/rule_form/RuleForm.tsx
··· 201 201 type 202 202 name 203 203 integration 204 + integrationTitle 205 + integrationLogoUrl 206 + integrationLogoWithBackgroundUrl 204 207 docsUrl 205 208 recommendedThresholds { 206 209 highPrecisionThreshold
-3
client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModal.tsx
··· 14 14 allSignals: CoreSignal[]; 15 15 onSelectSignal: (signal: CoreSignal, subcategoryOption?: string) => void; 16 16 onClose: () => void; 17 - // If the user has already selected a signal, the modal needs to know 18 - // which signal it is. 19 17 selectedSignal?: CoreSignal; 20 - // Whether this is the automated rule form ( proactive/autoenforcements ) 21 18 isAutomatedRule?: boolean; 22 19 }) { 23 20 const { visible, allSignals, onSelectSignal, onClose, selectedSignal, isAutomatedRule } =
+15 -4
client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalMenuItem.tsx
··· 10 10 import { CoreSignal } from '../../../../../models/signal'; 11 11 import { INTEGRATION_CONFIGS } from '../../../integrations/integrationConfigs'; 12 12 13 + /** Vendor/company name for display. Uses signal.integrationTitle (from API) when set, else static config, else formatted id. */ 13 14 export function vendorName(signal: CoreSignal) { 14 15 if (signal.type === GQLSignalType.Custom) { 15 16 return 'Custom'; 16 - } else if (!signal.integration) { 17 + } 18 + if (!signal.integration) { 17 19 return 'Coop'; 18 - } else { 19 - return INTEGRATION_CONFIGS.find((it) => it.name === signal.integration)! 20 - .title; 20 + } 21 + if (signal.integrationTitle) { 22 + return signal.integrationTitle; 23 + } 24 + const staticConfig = INTEGRATION_CONFIGS.find( 25 + (it) => it.name === signal.integration, 26 + ); 27 + if (staticConfig) { 28 + return staticConfig.title; 21 29 } 30 + return typeof signal.integration === 'string' 31 + ? signal.integration.replace(/_/g, ' ') 32 + : 'Plugin'; 22 33 } 23 34 24 35 export function signalDisplayName(signal: CoreSignal, hideVendor = true) {
+21 -3
client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalSignalDetailView.tsx
··· 21 21 ) => void; 22 22 }) { 23 23 const { signal, subcategories, onSelectSignal } = props; 24 - const integration = INTEGRATION_CONFIGS.find( 24 + const staticConfig = INTEGRATION_CONFIGS.find( 25 25 (it) => it.name === signal.integration, 26 26 ); 27 + const integrationTitle = 28 + signal.integrationTitle ?? 29 + staticConfig?.title ?? 30 + (typeof signal.integration === 'string' 31 + ? signal.integration 32 + .replace(/_/g, ' ') 33 + .toLowerCase() 34 + .replace(/^([a-z])|\s+([a-z])/g, (m) => m.toUpperCase()) 35 + : 'Coop'); 36 + // Signals use the logo-with-background variant. 37 + const rawLogoSrc = 38 + signal.integrationLogoWithBackgroundUrl ?? 39 + staticConfig?.logoWithBackground ?? 40 + LogoWhiteWithBackground; 41 + const logoSrc = 42 + typeof rawLogoSrc === 'string' && rawLogoSrc.startsWith('/') 43 + ? `${window.location.origin}${rawLogoSrc}` 44 + : rawLogoSrc; 27 45 28 46 const infoSectionData = [ 29 47 { ··· 33 51 <img 34 52 alt="logo" 35 53 className="w-8 h-8 mr-2 rounded-full" 36 - src={integration?.logoWithBackground ?? LogoWhiteWithBackground} 54 + src={logoSrc} 37 55 />{' '} 38 - {integration?.title ?? 'Coop'} 56 + {integrationTitle} 39 57 </div> 40 58 ), 41 59 },
+26 -15
client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalSignalGallery.tsx
··· 23 23 const filteredSignals = useMemo( 24 24 () => 25 25 allSignals 26 - // First filter out disabled signals 26 + // Show built-in Coop signals (no integration), known integrations (in INTEGRATION_CONFIGS), or plugin integrations (any other string) 27 27 .filter((signal) => 28 + signal.integration === null || 28 29 INTEGRATION_CONFIGS.some( 29 - (config) => 30 - signal.integration === config.name || signal.integration === null, 31 - ), 30 + (config) => signal.integration === config.name, 31 + ) || 32 + (typeof signal.integration === 'string' && 33 + signal.integration.length > 0), 32 34 ) 33 35 // Then filter out the text similarity score signals 34 36 .filter((it) => it.type !== 'TEXT_SIMILARITY_SCORE') ··· 40 42 ) 41 43 // Filter out 3rd party signals for demo orgs 42 44 .filter((signal) => !(isDemoOrg && signal.integration)), 43 - // eslint-disable-next-line react-hooks/exhaustive-deps 44 - [isDemoOrg, searchTerm], 45 + [allSignals, isDemoOrg, searchTerm], 45 46 ); 46 47 47 48 return ( ··· 85 86 `${b.signal.integration}_${b.signal.name}`, 86 87 ), 87 88 ) 88 - .map(({ signal, effectiveDisabledInfo }) => ( 89 - <div key={signal.name}> 89 + .map(({ signal, effectiveDisabledInfo }) => { 90 + const staticConfig = INTEGRATION_CONFIGS.find( 91 + (it) => it.name === signal.integration, 92 + ); 93 + // Signals use the logo-with-background variant. 94 + const rawPath = 95 + signal.integrationLogoWithBackgroundUrl ?? 96 + staticConfig?.logoWithBackground ?? 97 + undefined; 98 + const imagePath = 99 + typeof rawPath === 'string' && rawPath.startsWith('/') 100 + ? `${window.location.origin}${rawPath}` 101 + : rawPath; 102 + return ( 103 + <div key={signal.name}> 90 104 <RuleFormSignalModalMenuItem 91 105 key={signal.id} 92 106 signal={signal} 93 - imagePath={ 94 - INTEGRATION_CONFIGS.find( 95 - (it) => it.name === signal.integration, 96 - )?.logoWithBackground 97 - } 107 + imagePath={imagePath} 98 108 onClick={() => onSelectSignal(signal)} 99 109 infoButtonTapped={() => onSignalInfoSelected(signal)} 100 110 disabledInfo={effectiveDisabledInfo} 101 111 /> 102 - </div> 103 - ))} 112 + </div> 113 + ); 114 + })} 104 115 </div> 105 116 ) : ( 106 117 <RuleFormSignalModalNoSearchResults />
+11 -4
client/tsconfig.json
··· 7 7 "esModuleInterop": true, 8 8 "skipLibCheck": true, 9 9 "forceConsistentCasingInFileNames": true, 10 - "lib": ["dom", "dom.iterable", "esnext"], 10 + "lib": [ 11 + "dom", 12 + "dom.iterable", 13 + "esnext" 14 + ], 11 15 "allowJs": true, 12 16 "allowSyntheticDefaultImports": true, 13 17 "noFallthroughCasesInSwitch": true, ··· 17 21 "noEmit": true, 18 22 "downlevelIteration": true, 19 23 "paths": { 20 - "@/*": ["./src/*"], 21 - "@roostorg/types": ["../types"] 24 + "@/*": [ 25 + "./src/*" 26 + ] 22 27 } 23 28 }, 24 - "include": ["src"] 29 + "include": [ 30 + "src" 31 + ] 25 32 }
+24
integrations.config.example.json
··· 1 + { 2 + "integrations": [ 3 + { 4 + "package": "@roostorg/coop-integration-example", 5 + "enabled": true 6 + }, 7 + { 8 + "package": "../coop-integration-example", 9 + "enabled": false 10 + }, 11 + { 12 + "package": "@acme/coop-integration-acme", 13 + "enabled": false 14 + }, 15 + { 16 + "package": "./local-integrations/my-custom-integration", 17 + "enabled": false, 18 + "config": { 19 + "endpoint": "https://api.example.com", 20 + "timeoutMs": 5000 21 + } 22 + } 23 + ] 24 + }
+22
server/bin/www.ts
··· 5 5 6 6 import getBottle from '../iocContainer/index.js'; 7 7 import makeServer from '../server.js'; 8 + import { 9 + getIntegrationRegistry, 10 + getIntegrationsConfigPath, 11 + } from '../services/integrationRegistry/index.js'; 8 12 import { logErrorJson, logJson } from '../utils/logging.js'; 9 13 import { sleep } from '../utils/misc.js'; 10 14 11 15 const { app, shutdown } = await getBottle().then(async (bottle) => 12 16 makeServer(bottle.container), 13 17 ); 18 + 19 + // Eager-load integration registry so config/plugins are read at startup (fail fast, and so logo URLs are set). 20 + try { 21 + const registry = getIntegrationRegistry(); 22 + const configPath = getIntegrationsConfigPath(); 23 + const ids = registry.getConfigurableIds(); 24 + // eslint-disable-next-line no-restricted-syntax 25 + logJson( 26 + `Integrations: config=${configPath}, loaded=${ids.length} (${ids.join(', ')})`, 27 + ); 28 + } catch (err) { 29 + // eslint-disable-next-line no-restricted-syntax 30 + logErrorJson({ 31 + message: 'Failed to load integrations registry', 32 + error: err instanceof Error ? err : new Error(String(err)), 33 + }); 34 + process.exit(1); 35 + } 14 36 15 37 const port = parsePort(process.env.PORT) ?? 8080; 16 38 app.set('port', port);
+9 -7
server/condition_evaluator/conditionSet.ts
··· 261 261 export function getAllAggregationsInConditionSet( 262 262 conditionSet: ReadonlyDeep<ConditionSet>, 263 263 ): ReadonlyDeep<AggregationClause>[] { 264 - return conditionSet.conditions.flatMap((condition) => 265 - isConditionSet(condition) 266 - ? getAllAggregationsInConditionSet(condition) 267 - : condition.signal?.type === 'AGGREGATION' 268 - ? [condition.signal.args.aggregationClause] 269 - : [], 270 - ); 264 + return conditionSet.conditions.flatMap((condition) => { 265 + if (isConditionSet(condition)) { 266 + return getAllAggregationsInConditionSet(condition); 267 + } 268 + const sig = condition.signal; 269 + const args = 270 + sig?.type === 'AGGREGATION' ? sig.args : undefined; 271 + return args != null ? [args.aggregationClause] : []; 272 + }); 271 273 }
+8 -5
server/condition_evaluator/leafCondition.ts
··· 560 560 conditionSignalInfo: ReadonlyDeep<ConditionSignalInfo>, 561 561 ) { 562 562 if (conditionSignalInfo.type === 'AGGREGATION') { 563 - return evaluateAggregationRuntimeArgsForItem( 564 - evaluationContext, 565 - itemSubmission, 566 - conditionSignalInfo.args.aggregationClause, 567 - ); 563 + const args = conditionSignalInfo.args; 564 + if (args != null) { 565 + return evaluateAggregationRuntimeArgsForItem( 566 + evaluationContext, 567 + itemSubmission, 568 + args.aggregationClause, 569 + ); 570 + } 568 571 } 569 572 return undefined; 570 573 }
+106 -54
server/graphql/datasources/IntegrationApi.ts
··· 1 1 import { DataSource } from 'apollo-datasource'; 2 2 3 3 import { inject, type Dependencies } from '../../iocContainer/index.js'; 4 - import { 5 - configurableIntegrations, 6 - type ConfigurableIntegration, 7 - type CredentialTypes, 8 - } from '../../services/signalAuthService/index.js'; 9 - import { filterNullOrUndefined } from '../../utils/collections.js'; 4 + import '../../services/signalAuthService/index.js'; 5 + import { Integration } from '../../services/signalsService/index.js'; 10 6 import { 11 7 CoopError, 12 8 ErrorType, 13 9 type ErrorInstanceData, 14 10 } from '../../utils/errors.js'; 11 + import { getIntegrationRegistry } from '../../services/integrationRegistry/index.js'; 12 + import type { 13 + IntegrationManifestEntry, 14 + ModelCard, 15 + } from './integrationManifests.js'; 15 16 import { type GQLSetIntegrationConfigInput } from '../generated.js'; 16 17 17 - export type TIntegrationConfig = { 18 - [K in keyof CredentialTypes]: { 19 - name: K; 20 - apiCredential: { 21 - name: K; 22 - } & CredentialTypes[K]; 23 - }; 24 - }[ConfigurableIntegration]; 18 + export type TIntegrationConfigWithMetadata = Readonly<{ 19 + name: string; 20 + apiCredential: Readonly<Record<string, unknown>>; 21 + modelCard: ModelCard; 22 + modelCardLearnMoreUrl?: string; 23 + title: string; 24 + docsUrl: string; 25 + requiresConfig: boolean; 26 + logoUrl?: string; 27 + logoWithBackgroundUrl?: string; 28 + }>; 25 29 26 - export type TIntegrationCredential = TIntegrationConfig['apiCredential']; 30 + function defaultCredentialForIntegrationId( 31 + integrationId: string, 32 + ): Record<string, unknown> { 33 + switch (integrationId) { 34 + case Integration.GOOGLE_CONTENT_SAFETY_API: 35 + case Integration.OPEN_AI: 36 + return { apiKey: '' }; 37 + case Integration.ZENTROPI: 38 + return { apiKey: '', labelerVersions: [] }; 39 + default: 40 + return {}; 41 + } 42 + } 43 + 44 + function mergeManifest( 45 + integrationId: string, 46 + apiCredential: Record<string, unknown>, 47 + manifest: IntegrationManifestEntry, 48 + ): TIntegrationConfigWithMetadata { 49 + return { 50 + name: integrationId, 51 + apiCredential: { ...apiCredential, name: integrationId }, 52 + modelCard: manifest.modelCard, 53 + modelCardLearnMoreUrl: manifest.modelCardLearnMoreUrl, 54 + title: manifest.title, 55 + docsUrl: manifest.docsUrl, 56 + requiresConfig: manifest.requiresConfig, 57 + logoUrl: manifest.logoUrl, 58 + logoWithBackgroundUrl: manifest.logoWithBackgroundUrl, 59 + }; 60 + } 27 61 28 62 /** 29 63 * TODO: this whole class should probably be merged into the signal auth service. ··· 38 72 async setConfig( 39 73 params: GQLSetIntegrationConfigInput, 40 74 orgId: string, 41 - ): Promise<TIntegrationConfig> { 75 + ): Promise<TIntegrationConfigWithMetadata> { 42 76 const { apiCredential } = params; 43 77 44 78 if (apiCredential.googleContentSafetyApi) { 45 - return this.__private__setConfig( 79 + return this.setConfigByIntegrationId( 46 80 'GOOGLE_CONTENT_SAFETY_API', 47 81 { apiKey: apiCredential.googleContentSafetyApi.apiKey }, 48 82 orgId, 49 83 ); 50 84 } 51 - 52 85 if (apiCredential.openAi) { 53 - return this.__private__setConfig( 86 + return this.setConfigByIntegrationId( 54 87 'OPEN_AI', 55 88 { apiKey: apiCredential.openAi.apiKey }, 56 89 orgId, 57 90 ); 58 91 } 59 - 60 92 if (apiCredential.zentropi) { 61 - return this.__private__setConfig( 93 + return this.setConfigByIntegrationId( 62 94 'ZENTROPI', 63 95 { 64 96 apiKey: apiCredential.zentropi.apiKey, ··· 71 103 throw new Error('No credentials provided'); 72 104 } 73 105 74 - async getConfig( 106 + async setConfigByIntegrationId( 107 + integrationId: string, 108 + credential: Record<string, unknown>, 75 109 orgId: string, 76 - integration: ConfigurableIntegration, 77 - ): Promise<TIntegrationConfig | undefined> { 78 - const credential = await this.signalAuthService.get(integration, orgId); 79 - if (credential == null) { 80 - return undefined; 110 + ): Promise<TIntegrationConfigWithMetadata> { 111 + const registry = getIntegrationRegistry(); 112 + const manifest = registry.getManifest(integrationId); 113 + if (manifest == null) { 114 + throw new Error(`Unknown integration: ${integrationId}`); 81 115 } 82 - 83 - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions 84 - return { 85 - name: integration, 86 - apiCredential: { name: integration, ...credential }, 87 - } as TIntegrationConfig; 116 + const newCredential = await this.signalAuthService.setByIntegrationId( 117 + integrationId, 118 + orgId, 119 + credential, 120 + ); 121 + return mergeManifest(integrationId, newCredential, manifest); 88 122 } 89 123 90 - async getAllIntegrationConfigs(orgId: string): Promise<TIntegrationConfig[]> { 91 - const allConfigs = await Promise.all( 92 - configurableIntegrations.map(async (integration) => 93 - this.getConfig(orgId, integration), 94 - ), 124 + async getConfig( 125 + orgId: string, 126 + integrationId: string, 127 + ): Promise<TIntegrationConfigWithMetadata | undefined> { 128 + const registry = getIntegrationRegistry(); 129 + const manifest = registry.getManifest(integrationId); 130 + if (manifest == null) return undefined; 131 + const credential = await this.signalAuthService.getByIntegrationId( 132 + integrationId, 133 + orgId, 95 134 ); 96 - return filterNullOrUndefined(allConfigs); 135 + if (credential == null) return undefined; 136 + return mergeManifest(integrationId, credential, manifest); 97 137 } 98 138 99 - async __private__setConfig<T extends ConfigurableIntegration>( 100 - integration: T, 101 - credential: CredentialTypes[T], 139 + async getConfigWithMetadata( 102 140 orgId: string, 103 - ): Promise<TIntegrationConfig> { 104 - // When we're updating an existing credentials object, we have an id available, representing 105 - // the credentials object we need to update. When no id is passed in, then we're creating 106 - // a new credentials object. 107 - const newCredential = await this.signalAuthService.set( 108 - integration, 141 + integrationId: string, 142 + ): Promise<TIntegrationConfigWithMetadata> { 143 + const registry = getIntegrationRegistry(); 144 + const manifest = registry.getManifest(integrationId); 145 + if (manifest == null) { 146 + throw new Error(`Unknown integration: ${integrationId}`); 147 + } 148 + const credential = await this.signalAuthService.getByIntegrationId( 149 + integrationId, 109 150 orgId, 110 - credential, 111 151 ); 152 + const apiCredential = 153 + credential ?? defaultCredentialForIntegrationId(integrationId); 154 + return mergeManifest(integrationId, apiCredential, manifest); 155 + } 112 156 113 - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions 114 - return { 115 - name: integration, 116 - apiCredential: { name: integration, ...newCredential }, 117 - } as TIntegrationConfig; 157 + getAvailableIntegrations() { 158 + return getIntegrationRegistry().getAvailableIntegrations(); 159 + } 160 + 161 + async getAllIntegrationConfigs( 162 + orgId: string, 163 + ): Promise<TIntegrationConfigWithMetadata[]> { 164 + const ids = getIntegrationRegistry().getConfigurableIds(); 165 + return Promise.all( 166 + ids.map(async (integrationId) => 167 + this.getConfigWithMetadata(orgId, integrationId), 168 + ), 169 + ); 118 170 } 119 171 } 120 172
+3 -7
server/graphql/datasources/RuleApi.ts
··· 36 36 import { 37 37 isSignalId, 38 38 signalIsExternal, 39 - type ExternalSignalType, 40 39 type SignalId, 41 40 } from '../../services/signalsService/index.js'; 42 41 import { type ConditionSetWithResultAsLogged } from '../../services/analyticsLoggers/index.js'; ··· 881 880 processCondition(subCondition); 882 881 } 883 882 } else if ('signal' in condition && condition.signal) { 884 - // It's a leaf condition with a signal 883 + // It's a leaf condition with a signal (type is String to support plugin signals) 885 884 const { type, id } = condition.signal; 886 - // GQLSignalType values map to ExternalSignalType (conditions can only 887 - // contain user-visible external signals). Cast is safe since this comes 888 - // from validated GraphQL input. 889 885 let signalId: SignalId; 890 886 if (type === 'CUSTOM') { 891 887 // CUSTOM signals require an id field. The id comes from validated GraphQL 892 888 // input where it's a required Scalars['ID'], so we can safely cast it. 893 889 signalId = { type: 'CUSTOM' as const, id: id as NonEmptyString }; 894 890 } else { 895 - // Built-in signals only need the type 896 - signalId = { type: type as Exclude<ExternalSignalType, 'CUSTOM'> }; 891 + // Built-in and plugin signals: type is the signal type string 892 + signalId = { type }; 897 893 } 898 894 signalIds.push(signalId); 899 895 }
+13
server/graphql/datasources/integrationManifests.ts
··· 1 + /** 2 + * Re-export built-in manifests and types from the integration registry 3 + * so GraphQL datasources can use them without the registry depending on graphql. 4 + */ 5 + export { 6 + BUILT_IN_MANIFESTS, 7 + type AvailableIntegration, 8 + type IntegrationManifestEntry, 9 + type ModelCard, 10 + type ModelCardField, 11 + type ModelCardSection, 12 + type ModelCardSubsection, 13 + } from '../../services/integrationRegistry/index.js';
+253 -10
server/graphql/generated.ts
··· 470 470 readonly id: Scalars['ID']; 471 471 readonly name?: InputMaybe<Scalars['String']>; 472 472 readonly subcategory?: InputMaybe<Scalars['String']>; 473 - readonly type: GQLSignalType; 473 + readonly type: Scalars['String']; 474 474 }; 475 475 476 476 export type GQLConditionMatchingValuesInput = { ··· 1328 1328 export type GQLIntegrationApiCredential = 1329 1329 | GQLGoogleContentSafetyApiIntegrationApiCredential 1330 1330 | GQLOpenAiIntegrationApiCredential 1331 + | GQLPluginIntegrationApiCredential 1331 1332 | GQLZentropiIntegrationApiCredential; 1332 1333 1333 1334 export type GQLIntegrationApiCredentialInput = { ··· 1339 1340 export type GQLIntegrationConfig = { 1340 1341 readonly __typename?: 'IntegrationConfig'; 1341 1342 readonly apiCredential: GQLIntegrationApiCredential; 1342 - readonly name: GQLIntegration; 1343 + readonly docsUrl: Scalars['String']; 1344 + readonly logoUrl?: Maybe<Scalars['String']>; 1345 + readonly logoWithBackgroundUrl?: Maybe<Scalars['String']>; 1346 + readonly modelCard: GQLModelCard; 1347 + readonly modelCardLearnMoreUrl?: Maybe<Scalars['String']>; 1348 + readonly name: Scalars['String']; 1349 + readonly requiresConfig: Scalars['Boolean']; 1350 + readonly title: Scalars['String']; 1343 1351 }; 1344 1352 1345 1353 export type GQLIntegrationConfigQueryResponse = ··· 1381 1389 readonly type: ReadonlyArray<Scalars['String']>; 1382 1390 }; 1383 1391 1392 + export type GQLIntegrationMetadata = { 1393 + readonly __typename?: 'IntegrationMetadata'; 1394 + readonly docsUrl: Scalars['String']; 1395 + readonly logoUrl?: Maybe<Scalars['String']>; 1396 + readonly logoWithBackgroundUrl?: Maybe<Scalars['String']>; 1397 + readonly name: Scalars['String']; 1398 + readonly requiresConfig: Scalars['Boolean']; 1399 + readonly title: Scalars['String']; 1400 + }; 1401 + 1384 1402 export type GQLIntegrationNoInputCredentialsError = GQLError & { 1385 1403 readonly __typename?: 'IntegrationNoInputCredentialsError'; 1386 1404 readonly detail?: Maybe<Scalars['String']>; ··· 2163 2181 2164 2182 export type GQLMetricsTimeDivisionOptions = 2165 2183 (typeof GQLMetricsTimeDivisionOptions)[keyof typeof GQLMetricsTimeDivisionOptions]; 2184 + export type GQLModelCard = { 2185 + readonly __typename?: 'ModelCard'; 2186 + readonly modelName: Scalars['String']; 2187 + readonly releaseDate?: Maybe<Scalars['String']>; 2188 + readonly sections?: Maybe<ReadonlyArray<GQLModelCardSection>>; 2189 + readonly version: Scalars['String']; 2190 + }; 2191 + 2192 + export type GQLModelCardField = { 2193 + readonly __typename?: 'ModelCardField'; 2194 + readonly label: Scalars['String']; 2195 + readonly value: Scalars['String']; 2196 + }; 2197 + 2198 + export type GQLModelCardSection = { 2199 + readonly __typename?: 'ModelCardSection'; 2200 + readonly fields?: Maybe<ReadonlyArray<GQLModelCardField>>; 2201 + readonly id: Scalars['String']; 2202 + readonly subsections?: Maybe<ReadonlyArray<GQLModelCardSubsection>>; 2203 + readonly title: Scalars['String']; 2204 + }; 2205 + 2206 + export type GQLModelCardSubsection = { 2207 + readonly __typename?: 'ModelCardSubsection'; 2208 + readonly fields: ReadonlyArray<GQLModelCardField>; 2209 + readonly title: Scalars['String']; 2210 + }; 2211 + 2166 2212 export type GQLModeratorSafetySettingsInput = { 2167 2213 readonly moderatorSafetyBlurLevel: Scalars['Int']; 2168 2214 readonly moderatorSafetyGrayscale: Scalars['Boolean']; ··· 2336 2382 readonly setModeratorSafetySettings?: Maybe<GQLSetModeratorSafetySettingsSuccessResponse>; 2337 2383 readonly setMrtChartConfigurationSettings?: Maybe<GQLSetMrtChartConfigurationSettingsSuccessResponse>; 2338 2384 readonly setOrgDefaultSafetySettings?: Maybe<GQLSetModeratorSafetySettingsSuccessResponse>; 2385 + readonly setPluginIntegrationConfig: GQLSetIntegrationConfigResponse; 2339 2386 readonly signUp: GQLSignUpResponse; 2340 2387 readonly submitManualReviewDecision: GQLSubmitDecisionResponse; 2341 2388 readonly updateAccountInfo?: Maybe<Scalars['Boolean']>; ··· 2586 2633 2587 2634 export type GQLMutationSetOrgDefaultSafetySettingsArgs = { 2588 2635 orgDefaultSafetySettings: GQLModeratorSafetySettingsInput; 2636 + }; 2637 + 2638 + export type GQLMutationSetPluginIntegrationConfigArgs = { 2639 + input: GQLSetPluginIntegrationConfigInput; 2589 2640 }; 2590 2641 2591 2642 export type GQLMutationSignUpArgs = { ··· 3037 3088 readonly southwestCorner: GQLLatLngInput; 3038 3089 }; 3039 3090 3091 + export type GQLPluginIntegrationApiCredential = { 3092 + readonly __typename?: 'PluginIntegrationApiCredential'; 3093 + readonly credential: Scalars['JSONObject']; 3094 + }; 3095 + 3040 3096 export type GQLPolicy = { 3041 3097 readonly __typename?: 'Policy'; 3042 3098 readonly applyUserStrikeCountConfigToChildren?: Maybe<Scalars['Boolean']>; ··· 3105 3161 readonly allRuleInsights: GQLAllRuleInsights; 3106 3162 readonly apiKey: Scalars['String']; 3107 3163 readonly appealSettings?: Maybe<GQLAppealSettings>; 3164 + readonly availableIntegrations: ReadonlyArray<GQLIntegrationMetadata>; 3108 3165 readonly getCommentsForJob: ReadonlyArray<GQLManualReviewJobComment>; 3109 3166 readonly getDecidedJob?: Maybe<GQLManualReviewJob>; 3110 3167 readonly getDecidedJobFromJobId?: Maybe<GQLManualReviewJobWithDecisions>; ··· 3247 3304 }; 3248 3305 3249 3306 export type GQLQueryIntegrationConfigArgs = { 3250 - name: GQLIntegration; 3307 + name: Scalars['String']; 3251 3308 }; 3252 3309 3253 3310 export type GQLQueryInviteUserTokenArgs = { ··· 3922 3979 readonly _?: Maybe<Scalars['Boolean']>; 3923 3980 }; 3924 3981 3982 + export type GQLSetPluginIntegrationConfigInput = { 3983 + readonly credential: Scalars['JSONObject']; 3984 + readonly integrationId: Scalars['String']; 3985 + }; 3986 + 3925 3987 export type GQLSetUserStrikeThresholdInput = { 3926 3988 readonly actions: ReadonlyArray<Scalars['String']>; 3927 3989 readonly threshold: Scalars['Int']; ··· 3970 4032 readonly eligibleInputs: ReadonlyArray<GQLSignalInputType>; 3971 4033 readonly eligibleSubcategories: ReadonlyArray<GQLSignalSubcategory>; 3972 4034 readonly id: Scalars['ID']; 3973 - readonly integration?: Maybe<GQLIntegration>; 4035 + readonly integration?: Maybe<Scalars['String']>; 4036 + /** Logo URL for the integration. Null if not set or when signal has no integration. */ 4037 + readonly integrationLogoUrl?: Maybe<Scalars['String']>; 4038 + /** Logo-with-background URL for the integration. Null if not set or when signal has no integration. */ 4039 + readonly integrationLogoWithBackgroundUrl?: Maybe<Scalars['String']>; 4040 + /** Display name for the signal’s integration (from registry manifest). Null when signal has no integration. */ 4041 + readonly integrationTitle?: Maybe<Scalars['String']>; 3974 4042 readonly name: Scalars['String']; 3975 4043 readonly outputType: GQLSignalOutputType; 3976 4044 readonly pricingStructure: GQLSignalPricingStructure; ··· 3978 4046 readonly shouldPromptForMatchingValues: Scalars['Boolean']; 3979 4047 readonly subcategory?: Maybe<Scalars['String']>; 3980 4048 readonly supportedLanguages: GQLSupportedLanguages; 3981 - readonly type: GQLSignalType; 4049 + readonly type: Scalars['String']; 3982 4050 }; 3983 4051 3984 4052 export type GQLSignalArgs = GQLAggregationSignalArgs; ··· 4074 4142 export type GQLSignalType = (typeof GQLSignalType)[keyof typeof GQLSignalType]; 4075 4143 export type GQLSignalWithScore = { 4076 4144 readonly __typename?: 'SignalWithScore'; 4077 - readonly integration?: Maybe<GQLIntegration>; 4145 + readonly integration?: Maybe<Scalars['String']>; 4078 4146 readonly score: Scalars['String']; 4079 4147 readonly signalName: Scalars['String']; 4080 4148 readonly subcategory?: Maybe<Scalars['String']>; ··· 5196 5264 IntegrationApiCredential: 5197 5265 | GQLResolversTypes['GoogleContentSafetyApiIntegrationApiCredential'] 5198 5266 | GQLResolversTypes['OpenAiIntegrationApiCredential'] 5267 + | GQLResolversTypes['PluginIntegrationApiCredential'] 5199 5268 | GQLResolversTypes['ZentropiIntegrationApiCredential']; 5200 5269 IntegrationApiCredentialInput: GQLIntegrationApiCredentialInput; 5201 5270 IntegrationConfig: ResolverTypeWrapper< ··· 5210 5279 IntegrationConfigTooManyCredentialsError: ResolverTypeWrapper<GQLIntegrationConfigTooManyCredentialsError>; 5211 5280 IntegrationConfigUnsupportedIntegrationError: ResolverTypeWrapper<GQLIntegrationConfigUnsupportedIntegrationError>; 5212 5281 IntegrationEmptyInputCredentialsError: ResolverTypeWrapper<GQLIntegrationEmptyInputCredentialsError>; 5282 + IntegrationMetadata: ResolverTypeWrapper<GQLIntegrationMetadata>; 5213 5283 IntegrationNoInputCredentialsError: ResolverTypeWrapper<GQLIntegrationNoInputCredentialsError>; 5214 5284 InviteUserInput: GQLInviteUserInput; 5215 5285 InviteUserToken: ResolverTypeWrapper<GQLInviteUserToken>; ··· 5362 5432 } 5363 5433 >; 5364 5434 MetricsTimeDivisionOptions: GQLMetricsTimeDivisionOptions; 5435 + ModelCard: ResolverTypeWrapper<GQLModelCard>; 5436 + ModelCardField: ResolverTypeWrapper<GQLModelCardField>; 5437 + ModelCardSection: ResolverTypeWrapper<GQLModelCardSection>; 5438 + ModelCardSubsection: ResolverTypeWrapper<GQLModelCardSubsection>; 5365 5439 ModeratorSafetySettingsInput: GQLModeratorSafetySettingsInput; 5366 5440 MrtJobEnqueueSourceInfo: ResolverTypeWrapper<GQLMrtJobEnqueueSourceInfo>; 5367 5441 MutateAccessibleQueuesForUserSuccessResponse: ResolverTypeWrapper<GQLMutateAccessibleQueuesForUserSuccessResponse>; ··· 5497 5571 PendingInvite: ResolverTypeWrapper<GQLPendingInvite>; 5498 5572 PlaceBounds: ResolverTypeWrapper<GQLPlaceBounds>; 5499 5573 PlaceBoundsInput: GQLPlaceBoundsInput; 5574 + PluginIntegrationApiCredential: ResolverTypeWrapper<GQLPluginIntegrationApiCredential>; 5500 5575 Policy: ResolverTypeWrapper<GQLPolicy>; 5501 5576 PolicyActionCount: ResolverTypeWrapper<GQLPolicyActionCount>; 5502 5577 PolicyNameExistsError: ResolverTypeWrapper<GQLPolicyNameExistsError>; ··· 5619 5694 SetIntegrationConfigSuccessResponse: ResolverTypeWrapper<GQLSetIntegrationConfigSuccessResponse>; 5620 5695 SetModeratorSafetySettingsSuccessResponse: ResolverTypeWrapper<GQLSetModeratorSafetySettingsSuccessResponse>; 5621 5696 SetMrtChartConfigurationSettingsSuccessResponse: ResolverTypeWrapper<GQLSetMrtChartConfigurationSettingsSuccessResponse>; 5697 + SetPluginIntegrationConfigInput: GQLSetPluginIntegrationConfigInput; 5622 5698 SetUserStrikeThresholdInput: GQLSetUserStrikeThresholdInput; 5623 5699 SignUpInput: GQLSignUpInput; 5624 5700 SignUpResponse: ··· 6043 6119 IntegrationApiCredential: 6044 6120 | GQLResolversParentTypes['GoogleContentSafetyApiIntegrationApiCredential'] 6045 6121 | GQLResolversParentTypes['OpenAiIntegrationApiCredential'] 6122 + | GQLResolversParentTypes['PluginIntegrationApiCredential'] 6046 6123 | GQLResolversParentTypes['ZentropiIntegrationApiCredential']; 6047 6124 IntegrationApiCredentialInput: GQLIntegrationApiCredentialInput; 6048 6125 IntegrationConfig: Omit<GQLIntegrationConfig, 'apiCredential'> & { ··· 6055 6132 IntegrationConfigTooManyCredentialsError: GQLIntegrationConfigTooManyCredentialsError; 6056 6133 IntegrationConfigUnsupportedIntegrationError: GQLIntegrationConfigUnsupportedIntegrationError; 6057 6134 IntegrationEmptyInputCredentialsError: GQLIntegrationEmptyInputCredentialsError; 6135 + IntegrationMetadata: GQLIntegrationMetadata; 6058 6136 IntegrationNoInputCredentialsError: GQLIntegrationNoInputCredentialsError; 6059 6137 InviteUserInput: GQLInviteUserInput; 6060 6138 InviteUserToken: GQLInviteUserToken; ··· 6181 6259 MessageWithIpAddress: Omit<GQLMessageWithIpAddress, 'message'> & { 6182 6260 message: GQLResolversParentTypes['ContentItem']; 6183 6261 }; 6262 + ModelCard: GQLModelCard; 6263 + ModelCardField: GQLModelCardField; 6264 + ModelCardSection: GQLModelCardSection; 6265 + ModelCardSubsection: GQLModelCardSubsection; 6184 6266 ModeratorSafetySettingsInput: GQLModeratorSafetySettingsInput; 6185 6267 MrtJobEnqueueSourceInfo: GQLMrtJobEnqueueSourceInfo; 6186 6268 MutateAccessibleQueuesForUserSuccessResponse: GQLMutateAccessibleQueuesForUserSuccessResponse; ··· 6291 6373 PendingInvite: GQLPendingInvite; 6292 6374 PlaceBounds: GQLPlaceBounds; 6293 6375 PlaceBoundsInput: GQLPlaceBoundsInput; 6376 + PluginIntegrationApiCredential: GQLPluginIntegrationApiCredential; 6294 6377 Policy: GQLPolicy; 6295 6378 PolicyActionCount: GQLPolicyActionCount; 6296 6379 PolicyNameExistsError: GQLPolicyNameExistsError; ··· 6406 6489 SetIntegrationConfigSuccessResponse: GQLSetIntegrationConfigSuccessResponse; 6407 6490 SetModeratorSafetySettingsSuccessResponse: GQLSetModeratorSafetySettingsSuccessResponse; 6408 6491 SetMrtChartConfigurationSettingsSuccessResponse: GQLSetMrtChartConfigurationSettingsSuccessResponse; 6492 + SetPluginIntegrationConfigInput: GQLSetPluginIntegrationConfigInput; 6409 6493 SetUserStrikeThresholdInput: GQLSetUserStrikeThresholdInput; 6410 6494 SignUpInput: GQLSignUpInput; 6411 6495 SignUpResponse: ··· 8456 8540 __resolveType: TypeResolveFn< 8457 8541 | 'GoogleContentSafetyApiIntegrationApiCredential' 8458 8542 | 'OpenAiIntegrationApiCredential' 8543 + | 'PluginIntegrationApiCredential' 8459 8544 | 'ZentropiIntegrationApiCredential', 8460 8545 ParentType, 8461 8546 ContextType ··· 8472 8557 ParentType, 8473 8558 ContextType 8474 8559 >; 8475 - name?: Resolver<GQLResolversTypes['Integration'], ParentType, ContextType>; 8560 + docsUrl?: Resolver<GQLResolversTypes['String'], ParentType, ContextType>; 8561 + logoUrl?: Resolver< 8562 + Maybe<GQLResolversTypes['String']>, 8563 + ParentType, 8564 + ContextType 8565 + >; 8566 + logoWithBackgroundUrl?: Resolver< 8567 + Maybe<GQLResolversTypes['String']>, 8568 + ParentType, 8569 + ContextType 8570 + >; 8571 + modelCard?: Resolver<GQLResolversTypes['ModelCard'], ParentType, ContextType>; 8572 + modelCardLearnMoreUrl?: Resolver< 8573 + Maybe<GQLResolversTypes['String']>, 8574 + ParentType, 8575 + ContextType 8576 + >; 8577 + name?: Resolver<GQLResolversTypes['String'], ParentType, ContextType>; 8578 + requiresConfig?: Resolver< 8579 + GQLResolversTypes['Boolean'], 8580 + ParentType, 8581 + ContextType 8582 + >; 8583 + title?: Resolver<GQLResolversTypes['String'], ParentType, ContextType>; 8476 8584 __isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>; 8477 8585 }; 8478 8586 ··· 8592 8700 __isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>; 8593 8701 }; 8594 8702 8703 + export type GQLIntegrationMetadataResolvers< 8704 + ContextType = Context, 8705 + ParentType extends 8706 + GQLResolversParentTypes['IntegrationMetadata'] = GQLResolversParentTypes['IntegrationMetadata'], 8707 + > = { 8708 + docsUrl?: Resolver<GQLResolversTypes['String'], ParentType, ContextType>; 8709 + logoUrl?: Resolver< 8710 + Maybe<GQLResolversTypes['String']>, 8711 + ParentType, 8712 + ContextType 8713 + >; 8714 + logoWithBackgroundUrl?: Resolver< 8715 + Maybe<GQLResolversTypes['String']>, 8716 + ParentType, 8717 + ContextType 8718 + >; 8719 + name?: Resolver<GQLResolversTypes['String'], ParentType, ContextType>; 8720 + requiresConfig?: Resolver< 8721 + GQLResolversTypes['Boolean'], 8722 + ParentType, 8723 + ContextType 8724 + >; 8725 + title?: Resolver<GQLResolversTypes['String'], ParentType, ContextType>; 8726 + __isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>; 8727 + }; 8728 + 8595 8729 export type GQLIntegrationNoInputCredentialsErrorResolvers< 8596 8730 ContextType = Context, 8597 8731 ParentType extends ··· 9750 9884 __isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>; 9751 9885 }; 9752 9886 9887 + export type GQLModelCardResolvers< 9888 + ContextType = Context, 9889 + ParentType extends 9890 + GQLResolversParentTypes['ModelCard'] = GQLResolversParentTypes['ModelCard'], 9891 + > = { 9892 + modelName?: Resolver<GQLResolversTypes['String'], ParentType, ContextType>; 9893 + releaseDate?: Resolver< 9894 + Maybe<GQLResolversTypes['String']>, 9895 + ParentType, 9896 + ContextType 9897 + >; 9898 + sections?: Resolver< 9899 + Maybe<ReadonlyArray<GQLResolversTypes['ModelCardSection']>>, 9900 + ParentType, 9901 + ContextType 9902 + >; 9903 + version?: Resolver<GQLResolversTypes['String'], ParentType, ContextType>; 9904 + __isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>; 9905 + }; 9906 + 9907 + export type GQLModelCardFieldResolvers< 9908 + ContextType = Context, 9909 + ParentType extends 9910 + GQLResolversParentTypes['ModelCardField'] = GQLResolversParentTypes['ModelCardField'], 9911 + > = { 9912 + label?: Resolver<GQLResolversTypes['String'], ParentType, ContextType>; 9913 + value?: Resolver<GQLResolversTypes['String'], ParentType, ContextType>; 9914 + __isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>; 9915 + }; 9916 + 9917 + export type GQLModelCardSectionResolvers< 9918 + ContextType = Context, 9919 + ParentType extends 9920 + GQLResolversParentTypes['ModelCardSection'] = GQLResolversParentTypes['ModelCardSection'], 9921 + > = { 9922 + fields?: Resolver< 9923 + Maybe<ReadonlyArray<GQLResolversTypes['ModelCardField']>>, 9924 + ParentType, 9925 + ContextType 9926 + >; 9927 + id?: Resolver<GQLResolversTypes['String'], ParentType, ContextType>; 9928 + subsections?: Resolver< 9929 + Maybe<ReadonlyArray<GQLResolversTypes['ModelCardSubsection']>>, 9930 + ParentType, 9931 + ContextType 9932 + >; 9933 + title?: Resolver<GQLResolversTypes['String'], ParentType, ContextType>; 9934 + __isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>; 9935 + }; 9936 + 9937 + export type GQLModelCardSubsectionResolvers< 9938 + ContextType = Context, 9939 + ParentType extends 9940 + GQLResolversParentTypes['ModelCardSubsection'] = GQLResolversParentTypes['ModelCardSubsection'], 9941 + > = { 9942 + fields?: Resolver< 9943 + ReadonlyArray<GQLResolversTypes['ModelCardField']>, 9944 + ParentType, 9945 + ContextType 9946 + >; 9947 + title?: Resolver<GQLResolversTypes['String'], ParentType, ContextType>; 9948 + __isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>; 9949 + }; 9950 + 9753 9951 export type GQLMrtJobEnqueueSourceInfoResolvers< 9754 9952 ContextType = Context, 9755 9953 ParentType extends ··· 10351 10549 GQLMutationSetOrgDefaultSafetySettingsArgs, 10352 10550 'orgDefaultSafetySettings' 10353 10551 > 10552 + >; 10553 + setPluginIntegrationConfig?: Resolver< 10554 + GQLResolversTypes['SetIntegrationConfigResponse'], 10555 + ParentType, 10556 + ContextType, 10557 + RequireFields<GQLMutationSetPluginIntegrationConfigArgs, 'input'> 10354 10558 >; 10355 10559 signUp?: Resolver< 10356 10560 GQLResolversTypes['SignUpResponse'], ··· 11184 11388 >; 11185 11389 southwestCorner?: Resolver< 11186 11390 GQLResolversTypes['LatLng'], 11391 + ParentType, 11392 + ContextType 11393 + >; 11394 + __isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>; 11395 + }; 11396 + 11397 + export type GQLPluginIntegrationApiCredentialResolvers< 11398 + ContextType = Context, 11399 + ParentType extends 11400 + GQLResolversParentTypes['PluginIntegrationApiCredential'] = GQLResolversParentTypes['PluginIntegrationApiCredential'], 11401 + > = { 11402 + credential?: Resolver< 11403 + GQLResolversTypes['JSONObject'], 11187 11404 ParentType, 11188 11405 ContextType 11189 11406 >; ··· 11337 11554 ParentType, 11338 11555 ContextType 11339 11556 >; 11557 + availableIntegrations?: Resolver< 11558 + ReadonlyArray<GQLResolversTypes['IntegrationMetadata']>, 11559 + ParentType, 11560 + ContextType 11561 + >; 11340 11562 getCommentsForJob?: Resolver< 11341 11563 ReadonlyArray<GQLResolversTypes['ManualReviewJobComment']>, 11342 11564 ParentType, ··· 12712 12934 >; 12713 12935 id?: Resolver<GQLResolversTypes['ID'], ParentType, ContextType>; 12714 12936 integration?: Resolver< 12715 - Maybe<GQLResolversTypes['Integration']>, 12937 + Maybe<GQLResolversTypes['String']>, 12938 + ParentType, 12939 + ContextType 12940 + >; 12941 + integrationLogoUrl?: Resolver< 12942 + Maybe<GQLResolversTypes['String']>, 12943 + ParentType, 12944 + ContextType 12945 + >; 12946 + integrationLogoWithBackgroundUrl?: Resolver< 12947 + Maybe<GQLResolversTypes['String']>, 12948 + ParentType, 12949 + ContextType 12950 + >; 12951 + integrationTitle?: Resolver< 12952 + Maybe<GQLResolversTypes['String']>, 12716 12953 ParentType, 12717 12954 ContextType 12718 12955 >; ··· 12747 12984 ParentType, 12748 12985 ContextType 12749 12986 >; 12750 - type?: Resolver<GQLResolversTypes['SignalType'], ParentType, ContextType>; 12987 + type?: Resolver<GQLResolversTypes['String'], ParentType, ContextType>; 12751 12988 __isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>; 12752 12989 }; 12753 12990 ··· 12814 13051 GQLResolversParentTypes['SignalWithScore'] = GQLResolversParentTypes['SignalWithScore'], 12815 13052 > = { 12816 13053 integration?: Resolver< 12817 - Maybe<GQLResolversTypes['Integration']>, 13054 + Maybe<GQLResolversTypes['String']>, 12818 13055 ParentType, 12819 13056 ContextType 12820 13057 >; ··· 13993 14230 IntegrationConfigTooManyCredentialsError?: GQLIntegrationConfigTooManyCredentialsErrorResolvers<ContextType>; 13994 14231 IntegrationConfigUnsupportedIntegrationError?: GQLIntegrationConfigUnsupportedIntegrationErrorResolvers<ContextType>; 13995 14232 IntegrationEmptyInputCredentialsError?: GQLIntegrationEmptyInputCredentialsErrorResolvers<ContextType>; 14233 + IntegrationMetadata?: GQLIntegrationMetadataResolvers<ContextType>; 13996 14234 IntegrationNoInputCredentialsError?: GQLIntegrationNoInputCredentialsErrorResolvers<ContextType>; 13997 14235 InviteUserToken?: GQLInviteUserTokenResolvers<ContextType>; 13998 14236 InviteUserTokenExpiredError?: GQLInviteUserTokenExpiredErrorResolvers<ContextType>; ··· 14047 14285 MatchingBanks?: GQLMatchingBanksResolvers<ContextType>; 14048 14286 MatchingValues?: GQLMatchingValuesResolvers<ContextType>; 14049 14287 MessageWithIpAddress?: GQLMessageWithIpAddressResolvers<ContextType>; 14288 + ModelCard?: GQLModelCardResolvers<ContextType>; 14289 + ModelCardField?: GQLModelCardFieldResolvers<ContextType>; 14290 + ModelCardSection?: GQLModelCardSectionResolvers<ContextType>; 14291 + ModelCardSubsection?: GQLModelCardSubsectionResolvers<ContextType>; 14050 14292 MrtJobEnqueueSourceInfo?: GQLMrtJobEnqueueSourceInfoResolvers<ContextType>; 14051 14293 MutateAccessibleQueuesForUserSuccessResponse?: GQLMutateAccessibleQueuesForUserSuccessResponseResolvers<ContextType>; 14052 14294 MutateActionResponse?: GQLMutateActionResponseResolvers<ContextType>; ··· 14093 14335 PartialItemsSuccessResponse?: GQLPartialItemsSuccessResponseResolvers<ContextType>; 14094 14336 PendingInvite?: GQLPendingInviteResolvers<ContextType>; 14095 14337 PlaceBounds?: GQLPlaceBoundsResolvers<ContextType>; 14338 + PluginIntegrationApiCredential?: GQLPluginIntegrationApiCredentialResolvers<ContextType>; 14096 14339 Policy?: GQLPolicyResolvers<ContextType>; 14097 14340 PolicyActionCount?: GQLPolicyActionCountResolvers<ContextType>; 14098 14341 PolicyNameExistsError?: GQLPolicyNameExistsErrorResolvers<ContextType>;
+1 -1
server/graphql/modules/insights.ts
··· 23 23 24 24 type SignalWithScore { 25 25 signalName: String! 26 - integration: Integration 26 + integration: String 27 27 subcategory: String 28 28 score: String! 29 29 }
+119 -19
server/graphql/modules/integration.ts
··· 1 1 import { AuthenticationError } from 'apollo-server-express'; 2 2 3 - import { isConfigurableIntegration } from '../../services/signalAuthService/index.js'; 3 + import { getIntegrationRegistry } from '../../services/integrationRegistry/index.js'; 4 4 import { Integration } from '../../services/signalsService/index.js'; 5 5 import { isCoopErrorOfType } from '../../utils/errors.js'; 6 - import { assertUnreachable } from '../../utils/misc.js'; 7 6 import { 8 7 makeIntegrationConfigUnsupportedIntegrationError, 9 - type TIntegrationCredential, 10 8 } from '../datasources/IntegrationApi.js'; 9 + import type { TIntegrationConfigWithMetadata } from '../datasources/IntegrationApi.js'; 11 10 import { 11 + type GQLIntegrationConfig, 12 + type GQLIntegrationMetadata, 12 13 type GQLMutationResolvers, 13 14 type GQLQueryResolvers, 14 15 } from '../generated.js'; ··· 50 51 GoogleContentSafetyApiIntegrationApiCredential 51 52 | OpenAiIntegrationApiCredential 52 53 | ZentropiIntegrationApiCredential 54 + | PluginIntegrationApiCredential 55 + 56 + type ModelCardField { 57 + label: String! 58 + value: String! 59 + } 60 + 61 + type ModelCardSubsection { 62 + title: String! 63 + fields: [ModelCardField!]! 64 + } 65 + 66 + type ModelCardSection { 67 + id: String! 68 + title: String! 69 + subsections: [ModelCardSubsection!] 70 + fields: [ModelCardField!] 71 + } 72 + 73 + type ModelCard { 74 + modelName: String! 75 + version: String! 76 + releaseDate: String 77 + sections: [ModelCardSection!] 78 + } 79 + 80 + type IntegrationMetadata { 81 + name: String! 82 + title: String! 83 + docsUrl: String! 84 + requiresConfig: Boolean! 85 + logoUrl: String 86 + logoWithBackgroundUrl: String 87 + } 88 + 89 + type PluginIntegrationApiCredential { 90 + credential: JSONObject! 91 + } 53 92 54 93 type IntegrationConfig { 55 - name: Integration! 94 + name: String! 56 95 apiCredential: IntegrationApiCredential! 96 + modelCard: ModelCard! 97 + modelCardLearnMoreUrl: String 98 + title: String! 99 + docsUrl: String! 100 + requiresConfig: Boolean! 101 + logoUrl: String 102 + logoWithBackgroundUrl: String 57 103 } 58 104 59 105 input GoogleContentSafetyApiIntegrationApiCredentialInput { ··· 139 185 | IntegrationConfigUnsupportedIntegrationError 140 186 141 187 type Query { 142 - integrationConfig(name: Integration!): IntegrationConfigQueryResponse! 188 + integrationConfig(name: String!): IntegrationConfigQueryResponse! 189 + availableIntegrations: [IntegrationMetadata!]! 190 + } 191 + 192 + input SetPluginIntegrationConfigInput { 193 + integrationId: String! 194 + credential: JSONObject! 143 195 } 144 196 145 197 type Mutation { 146 198 setIntegrationConfig( 147 199 input: SetIntegrationConfigInput! 148 200 ): SetIntegrationConfigResponse! 201 + setPluginIntegrationConfig( 202 + input: SetPluginIntegrationConfigInput! 203 + ): SetIntegrationConfigResponse! 149 204 } 150 205 `; 151 206 152 - const IntegrationApiCredential: ResolverMap<TIntegrationCredential> = { 207 + const IntegrationApiCredential: ResolverMap<TIntegrationConfigWithMetadata['apiCredential']> = { 153 208 __resolveType(it) { 154 - const integrationName = it.name; 209 + const integrationName = (it as { name?: string }).name ?? ''; 155 210 switch (integrationName) { 156 211 case Integration.GOOGLE_CONTENT_SAFETY_API: 157 212 return 'GoogleContentSafetyApiIntegrationApiCredential'; ··· 160 215 case Integration.ZENTROPI: 161 216 return 'ZentropiIntegrationApiCredential'; 162 217 default: 163 - // TypeScript can't verify exhaustiveness here because GQL enum includes 164 - assertUnreachable( 165 - integrationName, 166 - `Unsupported integration: ${integrationName}`, 167 - ); 218 + return 'PluginIntegrationApiCredential'; 168 219 } 169 220 }, 170 221 }; ··· 177 228 throw new AuthenticationError('Unauthenticated User'); 178 229 } 179 230 180 - if (!isConfigurableIntegration(name)) { 231 + if (!getIntegrationRegistry().has(name)) { 181 232 throw makeIntegrationConfigUnsupportedIntegrationError({ 182 233 shouldErrorSpan: true, 183 234 }); 184 235 } 185 236 186 - const config = await context.dataSources.integrationAPI.getConfig( 187 - user.orgId, 188 - name, 237 + const config = 238 + await context.dataSources.integrationAPI.getConfigWithMetadata( 239 + user.orgId, 240 + name, 241 + ); 242 + 243 + return gqlSuccessResult( 244 + { config: config as GQLIntegrationConfig }, 245 + 'IntegrationConfigSuccessResult', 189 246 ); 190 - 191 - return gqlSuccessResult({ config }, 'IntegrationConfigSuccessResult'); 192 247 } catch (e: unknown) { 193 248 if ( 194 249 isCoopErrorOfType(e, 'IntegrationConfigUnsupportedIntegrationError') ··· 198 253 199 254 throw e; 200 255 } 256 + }, 257 + async availableIntegrations(_, __, context) { 258 + const user = context.getUser(); 259 + if (user == null) { 260 + throw new AuthenticationError('Unauthenticated User'); 261 + } 262 + return context.dataSources.integrationAPI.getAvailableIntegrations() as GQLIntegrationMetadata[]; 263 + }, 264 + }; 265 + 266 + const PluginIntegrationApiCredential = { 267 + credential(it: TIntegrationConfigWithMetadata['apiCredential']) { 268 + return it as Record<string, unknown>; 201 269 }, 202 270 }; 203 271 ··· 214 282 ); 215 283 216 284 return gqlSuccessResult( 217 - { config: newConfig }, 285 + { config: newConfig as GQLIntegrationConfig }, 286 + 'SetIntegrationConfigSuccessResponse', 287 + ); 288 + } catch (e: unknown) { 289 + if ( 290 + isCoopErrorOfType(e, [ 291 + 'IntegrationConfigTooManyCredentialsError', 292 + 'IntegrationNoInputCredentialsError', 293 + 'IntegrationEmptyInputCredentialsError', 294 + ]) 295 + ) { 296 + return gqlErrorResult(e); 297 + } 298 + 299 + throw e; 300 + } 301 + }, 302 + async setPluginIntegrationConfig(_, params, context) { 303 + try { 304 + const user = context.getUser(); 305 + if (user == null) { 306 + throw new AuthenticationError('Unauthenticated User'); 307 + } 308 + const newConfig = 309 + await context.dataSources.integrationAPI.setConfigByIntegrationId( 310 + params.input.integrationId, 311 + params.input.credential as Record<string, unknown>, 312 + user.orgId, 313 + ); 314 + 315 + return gqlSuccessResult( 316 + { config: newConfig as GQLIntegrationConfig }, 218 317 'SetIntegrationConfigSuccessResponse', 219 318 ); 220 319 } catch (e: unknown) { ··· 235 334 236 335 const resolvers = { 237 336 IntegrationApiCredential, 337 + PluginIntegrationApiCredential, 238 338 Query, 239 339 Mutation, 240 340 };
+4 -1
server/graphql/modules/org.ts
··· 4 4 import { isCoopErrorOfType } from '../../utils/errors.js'; 5 5 import { __throw } from '../../utils/misc.js'; 6 6 import { 7 + type GQLIntegrationConfig, 7 8 type GQLMatchingBanksResolvers, 8 9 type GQLMutationResolvers, 9 10 type GQLOrgResolvers, ··· 327 328 throw new AuthenticationError('User required.'); 328 329 } 329 330 330 - return context.dataSources.integrationAPI.getAllIntegrationConfigs(org.id); 331 + return context.dataSources.integrationAPI.getAllIntegrationConfigs( 332 + org.id, 333 + ) as Promise<GQLIntegrationConfig[]>; 331 334 }, 332 335 // customOnly param fetches only the org's custom signals 333 336 async signals(org, { customOnly }, context) {
+1 -1
server/graphql/modules/rule.ts
··· 371 371 372 372 input ConditionInputSignalInput { 373 373 id: ID! # JsonOf<SignalId> 374 - type: SignalType! 374 + type: String! 375 375 name: String 376 376 subcategory: String 377 377 args: SignalArgsInput
+38 -13
server/graphql/modules/signal.ts
··· 2 2 import { AuthenticationError } from 'apollo-server-express'; 3 3 import { type ReadonlyDeep } from 'type-fest'; 4 4 5 + import { getIntegrationRegistry } from '../../services/integrationRegistry/index.js'; 5 6 import { 6 7 getSignalIdString, 7 - type ExternalSignalType, 8 8 type SignalOutputType as TSignalOutputType, 9 9 } from '../../services/signalsService/index.js'; 10 10 import { safePick } from '../../utils/misc.js'; 11 11 import { 12 12 type GQLLanguage, 13 13 type GQLSignalArgsResolvers, 14 + type GQLSignalPricingStructure, 14 15 type GQLSignalResolvers, 15 - type GQLSignalType, 16 16 type GQLSupportedLanguagesResolvers, 17 17 } from '../generated.js'; 18 18 import { type ResolverMap } from '../resolvers.js'; ··· 52 52 53 53 type Signal { 54 54 id: ID! # JsonOf<SignalId> 55 - type: SignalType! 56 - integration: Integration 55 + type: String! 56 + integration: String 57 + """Display name for the signal’s integration (from registry manifest). Null when signal has no integration.""" 58 + integrationTitle: String 59 + """Logo URL for the integration. Null if not set or when signal has no integration.""" 60 + integrationLogoUrl: String 61 + """Logo-with-background URL for the integration. Null if not set or when signal has no integration.""" 62 + integrationLogoWithBackgroundUrl: String 57 63 name: String! 58 64 description: String! 59 65 docsUrl: String ··· 189 195 id(signal) { 190 196 return getSignalIdString(signal.id); 191 197 }, 192 - // NB: This resolver is unnecessary from a runtime POV (its functionality is 193 - // the same as the default resolver), but we keep it for type checking (i.e., 194 - // it verifies that our ExternalSignalType is assignable to the GQLSignalType 195 - // that we're supposed to be returning). 196 - type(signal): GQLSignalType { 197 - return signal.type as ExternalSignalType; 198 + // type is String! to support plugin signal types (e.g. RANDOM_SIGNAL_SELECTION) 199 + // in addition to built-in ExternalSignalType values. 200 + type(signal): string { 201 + return signal.type; 202 + }, 203 + integrationTitle(signal) { 204 + if (signal.integration == null) return null; 205 + return getIntegrationRegistry().getManifest(signal.integration)?.title ?? null; 206 + }, 207 + integrationLogoUrl(signal) { 208 + if (signal.integration == null) return null; 209 + return getIntegrationRegistry().getManifest(signal.integration)?.logoUrl ?? null; 210 + }, 211 + integrationLogoWithBackgroundUrl(signal) { 212 + if (signal.integration == null) return null; 213 + return getIntegrationRegistry().getManifest(signal.integration)?.logoWithBackgroundUrl ?? null; 198 214 }, 199 215 name(signal) { 200 216 return signal.displayName; 201 217 }, 202 - pricingStructure(signal) { 203 - return { type: signal.pricingStructure }; 218 + pricingStructure(signal): GQLSignalPricingStructure { 219 + const ps = signal.pricingStructure as 220 + | { type: string } 221 + | string; 222 + if (typeof ps === 'object' && 'type' in ps) { 223 + return ps as GQLSignalPricingStructure; 224 + } 225 + return { type: ps as GQLSignalPricingStructure['type'] }; 204 226 }, 205 227 async disabledInfo(signal, _, context) { 206 228 const user = context.getUser(); ··· 237 259 'ZENTROPI', 238 260 ); 239 261 if (config?.name === 'ZENTROPI') { 240 - const versions = config.apiCredential.labelerVersions ?? []; 262 + const versions = (config.apiCredential.labelerVersions ?? []) as Array<{ 263 + id: string; 264 + label: string; 265 + }>; 241 266 return versions.map((v) => ({ 242 267 id: v.id, 243 268 label: v.label,
+8
server/integrations.config.json
··· 1 + { 2 + "integrations": [ 3 + { 4 + "package": "@roostorg/coop-integration-example", 5 + "enabled": true 6 + } 7 + ] 8 + }
+17 -4
server/package-lock.json
··· 24 24 "@node-saml/passport-saml": "^5.1.0", 25 25 "@opentelemetry/api": "^1.8.0", 26 26 "@opentelemetry/semantic-conventions": "^1.22.0", 27 - "@roostorg/types": "^1.0.49", 27 + "@roostorg/coop-integration-example": "^1.0.0", 28 + "@roostorg/types": "^1.1.1", 28 29 "@sendgrid/mail": "^8.1.6", 29 30 "@stdlib/stats-binomial-test": "^0.0.7", 30 31 "@total-typescript/ts-reset": "^0.3.7", ··· 3953 3954 "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", 3954 3955 "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" 3955 3956 }, 3957 + "node_modules/@roostorg/coop-integration-example": { 3958 + "version": "1.0.0", 3959 + "resolved": "https://registry.npmjs.org/@roostorg/coop-integration-example/-/coop-integration-example-1.0.0.tgz", 3960 + "integrity": "sha512-vzb5sXSJHtg/tJxHOj0WbgVjf9ZKrul9zd1QsUGEScoEkHzybjISuELDM3F3IrUJMIdI+c5ISGto16VsC4cpCw==", 3961 + "license": "apache-2.0", 3962 + "engines": { 3963 + "node": ">=18" 3964 + }, 3965 + "peerDependencies": { 3966 + "@roostorg/types": ">=1.0.0" 3967 + } 3968 + }, 3956 3969 "node_modules/@roostorg/types": { 3957 - "version": "1.0.49", 3958 - "resolved": "https://registry.npmjs.org/@roostorg/types/-/types-1.0.49.tgz", 3959 - "integrity": "sha512-yrilSnPzP/KPryazRQbufMuwfBTLnne08TdkZqUW2QObh6oHkyDGwShQSLkWqRzKJxo+VSFZlbHGXdVUeGS3LQ==", 3970 + "version": "1.1.1", 3971 + "resolved": "https://registry.npmjs.org/@roostorg/types/-/types-1.1.1.tgz", 3972 + "integrity": "sha512-NhPYlG27wAQaD7AzWkL3LJHu52/QfK8lt9QMahUx7fbRtB4fYILy4fGcLQvt45gNQANoU78evW1UJftAB0B89Q==", 3960 3973 "license": "ISC", 3961 3974 "dependencies": { 3962 3975 "date-fns": "^2.29.3",
+3 -2
server/package.json
··· 7 7 "build": "tsc && npm run copy-assets", 8 8 "copy-assets": "copyfiles \"lib/**/*.lua\" transpiled/", 9 9 "start": "tsc-watch --onSuccess \"node --require dotenv/config ./transpiled/bin/www.js\"", 10 - "start:trace": "tsc-watch --onSuccess \"node --require dotenv/config --require ../nodejs-instrumentation/transpiled/autoinstrumentation.js ./transpiled/bin/www.js\"", 10 + "start:trace": "tsc-watch --onSuccess \"node --trace-warnings --require dotenv/config --require ../nodejs-instrumentation/transpiled/autoinstrumentation.js ./transpiled/bin/www.js\"", 11 11 "test": "npm run test:local", 12 12 "test:local": "NODE_OPTIONS=\"--no-warnings --loader ts-node/esm --require dotenv/config\" jest --watch --detectOpenHandles", 13 13 "test:prepush": "NODE_OPTIONS=\"--no-warnings --loader ts-node/esm --require dotenv/config\" jest --detectOpenHandles --no-cache --forceExit", ··· 38 38 "@node-saml/passport-saml": "^5.1.0", 39 39 "@opentelemetry/api": "^1.8.0", 40 40 "@opentelemetry/semantic-conventions": "^1.22.0", 41 - "@roostorg/types": "^1.0.49", 41 + "@roostorg/coop-integration-example": "^1.0.0", 42 + "@roostorg/types": "^1.1.1", 42 43 "@sendgrid/mail": "^8.1.6", 43 44 "@stdlib/stats-binomial-test": "^0.0.7", 44 45 "@total-typescript/ts-reset": "^0.3.7",
+7 -1
server/routes/index.ts
··· 2 2 import ActionRoutes from './action/ActionRoutes.js'; 3 3 import ContentRoutes from './content/ContentRoutes.js'; 4 4 import GDPRRoutes from './gdpr/gdprRoutes.js'; 5 + import IntegrationLogosRoutes from './integration_logos/IntegrationLogosRoutes.js'; 5 6 import ItemRoutes from './items/ItemRoutes.js'; 6 7 import PoliciesRoutes from './policies/PoliciesRoutes.js'; 7 8 import ReportingRoutes from './reporting/ReportingRoutes.js'; 8 9 import UserScoresRoutes from './user_scores/UserScoresRoutes.js'; 9 10 11 + /** Array of routes accepted by a controller. Uses wide types so GET (no body) and POST routes both fit. */ 12 + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Controller accepts any route shape 13 + export type ControllerRouteList = Route<any, any>[]; 14 + 10 15 export type Controller = { 11 16 // Path prefix expected to always start with a slash, given how we're 12 17 // concatenating it with `/api/v1` in our server setup. 13 18 pathPrefix: `/${string}`; 14 - routes: Route<any, any>[]; 19 + routes: ControllerRouteList; 15 20 }; 16 21 17 22 // eslint-disable-next-line @typescript-eslint/consistent-type-assertions ··· 23 28 UserScores: UserScoresRoutes, 24 29 Actions: ActionRoutes, 25 30 GDPR: GDPRRoutes, 31 + IntegrationLogos: IntegrationLogosRoutes, 26 32 } satisfies { [key: string]: Controller };
+12
server/routes/integration_logos/IntegrationLogosRoutes.ts
··· 1 + import { route } from '../../utils/route-helpers.js'; 2 + import { type Controller, type ControllerRouteList } from '../index.js'; 3 + import serveIntegrationLogo from './serveIntegrationLogo.js'; 4 + import serveIntegrationLogoWithBackground from './serveIntegrationLogoWithBackground.js'; 5 + 6 + export default { 7 + pathPrefix: '/integration-logos', 8 + routes: [ 9 + route.get<undefined>('/:integrationId/with-background', serveIntegrationLogoWithBackground), 10 + route.get<undefined>('/:integrationId', serveIntegrationLogo), 11 + ] as ControllerRouteList, 12 + } satisfies Controller;
+37
server/routes/integration_logos/serveIntegrationLogo.ts
··· 1 + import { type Dependencies } from '../../iocContainer/index.js'; 2 + import { getIntegrationRegistry } from '../../services/integrationRegistry/index.js'; 3 + import { makeNotFoundError } from '../../utils/errors.js'; 4 + import { type RequestHandlerWithBodies } from '../../utils/route-helpers.js'; 5 + 6 + /** 7 + * GET /integration-logos/:integrationId — serves the plugin logo file when 8 + * the integration manifest sets logoPath. Returns 404 if the integration 9 + * has no logo or logoPath was not set. 10 + */ 11 + export default function serveIntegrationLogo( 12 + _deps: Dependencies, 13 + ): RequestHandlerWithBodies<Record<string, never>, undefined> { 14 + return (req, res, next) => { 15 + const integrationId = req.params['integrationId']; 16 + if (!integrationId || integrationId.length === 0) { 17 + return next( 18 + makeNotFoundError('Missing integration id.', { shouldErrorSpan: true }), 19 + ); 20 + } 21 + const filePath = getIntegrationRegistry().getPluginLogoFilePath(integrationId); 22 + if (filePath === undefined) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition -- runtime guard for missing plugin logo 23 + return next( 24 + makeNotFoundError('Integration logo not found.', { 25 + shouldErrorSpan: true, 26 + }), 27 + ); 28 + } 29 + // Path was validated at plugin load (under package root); safe to send. 30 + res.setHeader('Cache-Control', 'public, max-age=86400'); 31 + res.sendFile(filePath, (err) => { 32 + if (err != null && !res.headersSent) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition -- sendFile callback err is Error | null per types 33 + next(err); 34 + } 35 + }); 36 + }; 37 + }
+35
server/routes/integration_logos/serveIntegrationLogoWithBackground.ts
··· 1 + import { type Dependencies } from '../../iocContainer/index.js'; 2 + import { getIntegrationRegistry } from '../../services/integrationRegistry/index.js'; 3 + import { makeNotFoundError } from '../../utils/errors.js'; 4 + import { type RequestHandlerWithBodies } from '../../utils/route-helpers.js'; 5 + 6 + /** 7 + * GET /integration-logos/:integrationId/with-background — serves the plugin 8 + * "with background" logo when the manifest sets logoWithBackgroundPath. 9 + */ 10 + export default function serveIntegrationLogoWithBackground( 11 + _deps: Dependencies, 12 + ): RequestHandlerWithBodies<Record<string, never>, undefined> { 13 + return (req, res, next) => { 14 + const integrationId = req.params['integrationId']; 15 + if (!integrationId || integrationId.length === 0) { 16 + return next( 17 + makeNotFoundError('Missing integration id.', { shouldErrorSpan: true }), 18 + ); 19 + } 20 + const filePath = getIntegrationRegistry().getPluginLogoWithBackgroundFilePath(integrationId); 21 + if (filePath === undefined) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition -- runtime guard for missing plugin logo 22 + return next( 23 + makeNotFoundError('Integration logo (with-background) not found.', { 24 + shouldErrorSpan: true, 25 + }), 26 + ); 27 + } 28 + res.setHeader('Cache-Control', 'public, max-age=86400'); 29 + res.sendFile(filePath, (err) => { 30 + if (err != null && !res.headersSent) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition -- sendFile callback err is Error | null per types 31 + next(err); 32 + } 33 + }); 34 + }; 35 + }
+6 -4
server/services/analyticsQueries/RuleActionInsights.ts
··· 10 10 import { type NormalizedItemData } from '../../services/itemProcessingService/index.js'; 11 11 import { 12 12 BuiltInThirdPartySignalType, 13 + SignalType, 13 14 UserCreatedExternalSignalType, 14 15 integrationForSignalType, 15 16 type Integration, 16 - type SignalType, 17 17 } from '../../services/signalsService/index.js'; 18 18 import { jsonParse, type JsonOf } from '../../utils/encoding.js'; 19 19 import { ··· 476 476 type GatherSignalsLeafConditionWithResult = { 477 477 signal?: { 478 478 name: string; 479 - type: SignalType; 479 + type: string; 480 480 subcategory?: string | null; 481 481 } | null; 482 482 result?: { score?: string | null } | null; ··· 486 486 conditions: GatherSignalsConditionWithResult[]; 487 487 }; 488 488 489 - const signalResultShouldBeDisplayed = (type: SignalType) => 489 + /** Includes built-in third-party, user-created, and plugin signal types (string). */ 490 + const signalResultShouldBeDisplayed = (type: string) => 490 491 Object.hasOwn(BuiltInThirdPartySignalType, type) || 491 - Object.hasOwn(UserCreatedExternalSignalType, type); 492 + Object.hasOwn(UserCreatedExternalSignalType, type) || 493 + !Object.hasOwn(SignalType, type); // plugin signal types not in SignalType enum 492 494 493 495 /** 494 496 * When we display signal results in the UI (specifically in the rule samples
+113
server/services/integrationRegistry/index.ts
··· 1 + /** 2 + * Dynamic integration registry: built-in manifests + plugins loaded from 3 + * integrations.config.json. Single source of truth for available integrations. 4 + */ 5 + 6 + import { 7 + BUILT_IN_MANIFESTS, 8 + type AvailableIntegration, 9 + type IntegrationManifestEntry, 10 + } from './integrationManifests.js'; 11 + import { 12 + getIntegrationsConfigPath, 13 + loadIntegrationsConfig, 14 + } from './loadIntegrationsConfig.js'; 15 + import { loadPlugins, type PluginEntry } from './loadPlugins.js'; 16 + 17 + export type IntegrationRegistry = Readonly<{ 18 + getManifest(id: string): IntegrationManifestEntry | undefined; 19 + getAvailableIntegrations(): AvailableIntegration[]; 20 + has(id: string): boolean; 21 + getConfigurableIds(): readonly string[]; 22 + /** Plugin (packageSpec, integrationId) entries for loading plugin signals. */ 23 + getPluginEntries(): readonly PluginEntry[]; 24 + /** Absolute path to main plugin logo (manifest.logoPath). */ 25 + getPluginLogoFilePath(integrationId: string): string | undefined; 26 + /** Absolute path to "with background" logo (manifest.logoWithBackgroundPath), if set. */ 27 + getPluginLogoWithBackgroundFilePath(integrationId: string): string | undefined; 28 + }>; 29 + 30 + function buildRegistry(): IntegrationRegistry { 31 + const config = loadIntegrationsConfig(); 32 + const configPath = getIntegrationsConfigPath(); 33 + let result: ReturnType<typeof loadPlugins>; 34 + try { 35 + result = loadPlugins(config, configPath); 36 + } catch (err) { 37 + throw new Error( 38 + `Integration plugin loading failed: ${err instanceof Error ? err.message : String(err)}`, 39 + ); 40 + } 41 + const map = new Map<string, IntegrationManifestEntry>(); 42 + for (const [id, entry] of Object.entries(BUILT_IN_MANIFESTS)) { 43 + map.set(id, entry); 44 + } 45 + for (const [id, entry] of result.manifests) { 46 + map.set(id, entry); 47 + } 48 + const configurableIds = Array.from(map.keys()); 49 + const pluginEntries = result.pluginEntries; 50 + const pluginLogoPaths = result.pluginLogoPaths; 51 + const pluginLogoWithBackgroundPaths = result.pluginLogoWithBackgroundPaths; 52 + 53 + return { 54 + getManifest(id: string): IntegrationManifestEntry | undefined { 55 + return map.get(id); 56 + }, 57 + getAvailableIntegrations(): AvailableIntegration[] { 58 + return configurableIds.map((name) => { 59 + const manifest = map.get(name)!; 60 + return { 61 + name, 62 + title: manifest.title, 63 + docsUrl: manifest.docsUrl, 64 + requiresConfig: manifest.requiresConfig, 65 + logoUrl: manifest.logoUrl, 66 + logoWithBackgroundUrl: manifest.logoWithBackgroundUrl, 67 + }; 68 + }); 69 + }, 70 + has(id: string): boolean { 71 + return map.has(id); 72 + }, 73 + getConfigurableIds(): readonly string[] { 74 + return configurableIds; 75 + }, 76 + getPluginEntries(): readonly PluginEntry[] { 77 + return pluginEntries; 78 + }, 79 + getPluginLogoFilePath(integrationId: string): string | undefined { 80 + return pluginLogoPaths.get(integrationId); 81 + }, 82 + getPluginLogoWithBackgroundFilePath(integrationId: string): string | undefined { 83 + return pluginLogoWithBackgroundPaths.get(integrationId); 84 + }, 85 + }; 86 + } 87 + 88 + let cachedRegistry: IntegrationRegistry | null = null; 89 + 90 + /** 91 + * Returns the integration registry (built once on first call). 92 + */ 93 + export function getIntegrationRegistry(): IntegrationRegistry { 94 + if (cachedRegistry == null) { 95 + cachedRegistry = buildRegistry(); 96 + } 97 + return cachedRegistry; 98 + } 99 + 100 + export { 101 + BUILT_IN_MANIFESTS, 102 + type AvailableIntegration, 103 + type IntegrationManifestEntry, 104 + type ModelCard, 105 + type ModelCardField, 106 + type ModelCardSection, 107 + type ModelCardSubsection, 108 + } from './integrationManifests.js'; 109 + export { 110 + getIntegrationsConfigPath, 111 + loadIntegrationsConfig, 112 + } from './loadIntegrationsConfig.js'; 113 + export { loadPlugins } from './loadPlugins.js';
+277
server/services/integrationRegistry/integrationManifests.ts
··· 1 + /** 2 + * Backend manifest entries for built-in integrations. 3 + * The dynamic integration registry merges these with loaded plugins. 4 + * Lives in the registry (not graphql) so transport-agnostic code can import it. 5 + */ 6 + 7 + const REQUIRED_SECTION_IDS = ['modelDetails', 'technicalIntegration'] as const; 8 + 9 + export type ModelCardField = Readonly<{ label: string; value: string }>; 10 + export type ModelCardSubsection = Readonly<{ 11 + title: string; 12 + fields: readonly ModelCardField[]; 13 + }>; 14 + export type ModelCardSection = Readonly<{ 15 + id: string; 16 + title: string; 17 + subsections?: readonly ModelCardSubsection[]; 18 + fields?: readonly ModelCardField[]; 19 + }>; 20 + export type ModelCard = Readonly<{ 21 + modelName: string; 22 + version: string; 23 + releaseDate?: string; 24 + sections?: readonly ModelCardSection[]; 25 + }>; 26 + 27 + export type IntegrationManifestEntry = Readonly<{ 28 + modelCard: ModelCard; 29 + modelCardLearnMoreUrl?: string; 30 + /** Display name for the integration (e.g. "Google Content Safety API"). */ 31 + title: string; 32 + /** Link to documentation or product page. */ 33 + docsUrl: string; 34 + /** Whether the integration requires the user to supply config (e.g. API key or other settings). */ 35 + requiresConfig: boolean; 36 + /** Optional URL to a logo image. When absent, client may use a fallback. */ 37 + logoUrl?: string; 38 + /** Optional URL to a logo variant (e.g. with background). */ 39 + logoWithBackgroundUrl?: string; 40 + }>; 41 + 42 + function assertModelCardHasRequiredSections(card: ModelCard): void { 43 + const sectionIds = new Set((card.sections ?? []).map((s) => s.id)); 44 + for (const requiredId of REQUIRED_SECTION_IDS) { 45 + if (!sectionIds.has(requiredId)) { 46 + throw new Error( 47 + `Model card must include a section with id "${requiredId}".`, 48 + ); 49 + } 50 + } 51 + } 52 + 53 + const GOOGLE_CONTENT_SAFETY: IntegrationManifestEntry = { 54 + modelCard: { 55 + modelName: 'Content Safety API', 56 + version: '1.x', 57 + releaseDate: 'Ongoing', 58 + sections: [ 59 + { 60 + id: 'modelDetails', 61 + title: 'Model Details', 62 + subsections: [ 63 + { 64 + title: 'Basic Information', 65 + fields: [ 66 + { label: 'Model Name', value: 'Content Safety API' }, 67 + { label: 'Developed By', value: 'Google' }, 68 + { 69 + label: 'Documentation URL', 70 + value: 'https://protectingchildren.google/tools-for-partners/', 71 + }, 72 + ], 73 + }, 74 + { 75 + title: 'Intended Use', 76 + fields: [ 77 + { 78 + label: 'Primary Use Case', 79 + value: 80 + 'Child safety prioritization recommendations on user-generated content.', 81 + }, 82 + { 83 + label: 'Target Users', 84 + value: 'Platforms and partners conducting content moderation.', 85 + }, 86 + { 87 + label: 'Important Note', 88 + value: 89 + 'Users must conduct their own manual review and comply with applicable reporting laws. The API does not replace human judgment.', 90 + }, 91 + ], 92 + }, 93 + ], 94 + }, 95 + { 96 + id: 'technicalIntegration', 97 + title: 'Technical Integration', 98 + fields: [ 99 + { 100 + label: 'Authentication', 101 + value: 'API key (apply via Google\'s partner tools).', 102 + }, 103 + { 104 + label: 'Integration Points', 105 + value: 106 + 'Coop sends content to the API and uses the returned prioritization in moderation workflows.', 107 + }, 108 + ], 109 + }, 110 + ], 111 + }, 112 + modelCardLearnMoreUrl: 'https://modelcards.withgoogle.com/', 113 + title: 'Google Content Safety API', 114 + docsUrl: 'https://protectingchildren.google/tools-for-partners/', 115 + requiresConfig: true, 116 + }; 117 + 118 + const OPENAI: IntegrationManifestEntry = { 119 + modelCard: { 120 + modelName: 'OpenAI', 121 + version: 'v0.0', 122 + releaseDate: 'January 2026', 123 + sections: [ 124 + { 125 + id: 'modelDetails', 126 + title: 'Model Details', 127 + subsections: [ 128 + { 129 + title: 'Basic Information', 130 + fields: [ 131 + { label: 'Model Name', value: 'OpenAI' }, 132 + { label: 'Version', value: 'v0.0' }, 133 + { label: 'Release Date', value: 'January 2026' }, 134 + { label: 'License Type', value: 'API Access Only' }, 135 + { 136 + label: 'Documentation URL', 137 + value: 'https://platform.openai.com/docs', 138 + }, 139 + ], 140 + }, 141 + { 142 + title: 'Model Architecture', 143 + fields: [ 144 + { label: 'Base Architecture', value: 'Transformer-based' }, 145 + { 146 + label: 'Input/output specifications', 147 + value: 148 + 'API-dependent; see OpenAI documentation for the specific model in use.', 149 + }, 150 + ], 151 + }, 152 + { 153 + title: 'Intended Use', 154 + fields: [ 155 + { 156 + label: 'Primary Use Case', 157 + value: 158 + 'Content moderation and safety-related classification via OpenAI APIs.', 159 + }, 160 + { 161 + label: 'Target Users', 162 + value: 'Platforms using Coop for moderation.', 163 + }, 164 + { 165 + label: 'Deployment Context', 166 + value: 'Used within Coop to call OpenAI APIs with your API key.', 167 + }, 168 + ], 169 + }, 170 + ], 171 + }, 172 + { 173 + id: 'technicalIntegration', 174 + title: 'Technical Integration', 175 + fields: [ 176 + { 177 + label: 'Credentials', 178 + value: 'This integration requires one API Key.', 179 + }, 180 + { 181 + label: 'Documentation', 182 + value: 'https://platform.openai.com/docs', 183 + }, 184 + ], 185 + }, 186 + ], 187 + }, 188 + modelCardLearnMoreUrl: 'https://modelcards.withgoogle.com/', 189 + title: 'OpenAI', 190 + docsUrl: 'https://platform.openai.com/docs', 191 + requiresConfig: true, 192 + }; 193 + 194 + const ZENTROPI: IntegrationManifestEntry = { 195 + modelCard: { 196 + modelName: 'Zentropi', 197 + version: '1.x', 198 + releaseDate: 'Ongoing', 199 + sections: [ 200 + { 201 + id: 'modelDetails', 202 + title: 'Model Details', 203 + subsections: [ 204 + { 205 + title: 'Basic Information', 206 + fields: [ 207 + { label: 'Model Name', value: 'Zentropi' }, 208 + { label: 'Developed By', value: 'Zentropi' }, 209 + { 210 + label: 'Documentation URL', 211 + value: 'https://docs.zentropi.ai', 212 + }, 213 + ], 214 + }, 215 + { 216 + title: 'Intended Use', 217 + fields: [ 218 + { 219 + label: 'Primary Use Case', 220 + value: 221 + 'Content labeling and moderation via configurable labeler versions.', 222 + }, 223 + { 224 + label: 'Target Users', 225 + value: 'Platforms using Coop with Zentropi labelers.', 226 + }, 227 + { 228 + label: 'Integration Points', 229 + value: 230 + 'API key plus optional labeler versions (id and label) for each model you use.', 231 + }, 232 + ], 233 + }, 234 + ], 235 + }, 236 + { 237 + id: 'technicalIntegration', 238 + title: 'Technical Integration', 239 + fields: [ 240 + { 241 + label: 'Credentials', 242 + value: 243 + 'API Key plus optional Labeler Versions (id and label per version).', 244 + }, 245 + { label: 'Documentation', value: 'https://docs.zentropi.ai' }, 246 + ], 247 + }, 248 + ], 249 + }, 250 + modelCardLearnMoreUrl: 'https://modelcards.withgoogle.com/', 251 + title: 'Zentropi', 252 + docsUrl: 'https://docs.zentropi.ai', 253 + requiresConfig: true, 254 + }; 255 + 256 + /** Built-in integration manifests (id -> entry). Merged with loaded plugins by the integration registry. */ 257 + export const BUILT_IN_MANIFESTS: Readonly< 258 + Record<string, IntegrationManifestEntry> 259 + > = { 260 + GOOGLE_CONTENT_SAFETY_API: GOOGLE_CONTENT_SAFETY, 261 + OPEN_AI: OPENAI, 262 + ZENTROPI, 263 + }; 264 + 265 + // Validate required sections at load time 266 + for (const entry of Object.values(BUILT_IN_MANIFESTS)) { 267 + assertModelCardHasRequiredSections(entry.modelCard); 268 + } 269 + 270 + export type AvailableIntegration = Readonly<{ 271 + name: string; 272 + title: string; 273 + docsUrl: string; 274 + requiresConfig: boolean; 275 + logoUrl?: string; 276 + logoWithBackgroundUrl?: string; 277 + }>;
+62
server/services/integrationRegistry/loadIntegrationsConfig.ts
··· 1 + /** 2 + * Loads and validates the adopters' integrations config file. 3 + * Path: INTEGRATIONS_CONFIG_PATH env or cwd/integrations.config.json. 4 + */ 5 + 6 + import { existsSync, readFileSync } from 'fs'; 7 + import path from 'path'; 8 + 9 + import type { CoopIntegrationsConfig } from '@roostorg/types'; 10 + 11 + import { jsonParse } from '../../utils/encoding.js'; 12 + import type { JsonOf } from '../../utils/encoding.js'; 13 + 14 + function getConfigPath(): string { 15 + const envPath = process.env.INTEGRATIONS_CONFIG_PATH; 16 + if (envPath != null && envPath !== '') { 17 + return path.isAbsolute(envPath) ? envPath : path.join(process.cwd(), envPath); 18 + } 19 + const cwdPath = path.join(process.cwd(), 'integrations.config.json'); 20 + // eslint-disable-next-line security/detect-non-literal-fs-filename -- path from cwd/env, not user input 21 + if (existsSync(cwdPath)) return cwdPath; 22 + // When started from repo root (e.g. npm run start), cwd has no integrations.config.json; try server/ 23 + const serverPath = path.join(process.cwd(), 'server', 'integrations.config.json'); 24 + // eslint-disable-next-line security/detect-non-literal-fs-filename -- path from cwd, not user input 25 + if (existsSync(serverPath)) return serverPath; 26 + return cwdPath; 27 + } 28 + 29 + /** 30 + * Returns the path to the integrations config file (for resolving relative package specs). 31 + */ 32 + export function getIntegrationsConfigPath(): string { 33 + return getConfigPath(); 34 + } 35 + 36 + /** 37 + * Loads CoopIntegrationsConfig from the configured path. 38 + * Returns { integrations: [] } if the file is missing (built-ins only). 39 + */ 40 + export function loadIntegrationsConfig(): CoopIntegrationsConfig { 41 + const configPath = getConfigPath(); 42 + try { 43 + // eslint-disable-next-line security/detect-non-literal-fs-filename -- path from getConfigPath (env/cwd), not user input 44 + const raw = readFileSync(configPath, 'utf-8'); 45 + const parsed = jsonParse(raw as JsonOf<Record<string, unknown>>); 46 + if (typeof parsed !== 'object') { 47 + return { integrations: [] }; 48 + } 49 + const o = parsed; 50 + const integrations = Array.isArray(o.integrations) ? o.integrations : []; 51 + const entries = integrations.filter( 52 + (e): e is { package: string; enabled?: boolean; config?: Record<string, unknown> } => 53 + e != null && typeof e === 'object' && typeof (e as Record<string, unknown>).package === 'string', 54 + ); 55 + return { integrations: entries }; 56 + } catch (err: unknown) { 57 + if (err != null && typeof err === 'object' && 'code' in err && err.code === 'ENOENT') { 58 + return { integrations: [] }; 59 + } 60 + throw err; 61 + } 62 + }
+192
server/services/integrationRegistry/loadPlugins.ts
··· 1 + /** 2 + * Loads integration plugin packages from the config and maps their manifests 3 + * to the server's IntegrationManifestEntry shape. Can also collect plugin 4 + * entries for later signal loading (createSignals). 5 + */ 6 + 7 + import fs from 'node:fs'; 8 + import { createRequire } from 'module'; 9 + import path from 'path'; 10 + 11 + import { 12 + assertModelCardHasRequiredSections, 13 + isCoopIntegrationPlugin, 14 + } from '@roostorg/types'; 15 + import type { CoopIntegrationsConfig } from '@roostorg/types'; 16 + 17 + import type { 18 + IntegrationManifestEntry, 19 + ModelCard, 20 + } from './integrationManifests.js'; 21 + 22 + export type PluginManifestMap = Map<string, IntegrationManifestEntry>; 23 + 24 + export type PluginEntry = Readonly<{ packageSpec: string; integrationId: string }>; 25 + 26 + export type LoadPluginsResult = Readonly<{ 27 + manifests: PluginManifestMap; 28 + pluginEntries: readonly PluginEntry[]; 29 + /** integrationId -> absolute path to main logo (manifest.logoPath). */ 30 + pluginLogoPaths: ReadonlyMap<string, string>; 31 + /** integrationId -> absolute path to "with background" logo (manifest.logoWithBackgroundPath). */ 32 + pluginLogoWithBackgroundPaths: ReadonlyMap<string, string>; 33 + }>; 34 + 35 + const INTEGRATION_LOGOS_PATH_PREFIX = '/api/v1/integration-logos'; 36 + 37 + function findPackageRoot(startDir: string): string { 38 + let dir = path.resolve(startDir); 39 + const root = path.parse(dir).root; 40 + while (dir !== root) { 41 + // eslint-disable-next-line security/detect-non-literal-fs-filename -- dir is from require.resolve (package entry), not user input 42 + if (fs.existsSync(path.join(dir, 'package.json'))) { 43 + return dir; 44 + } 45 + dir = path.dirname(dir); 46 + } 47 + return startDir; 48 + } 49 + 50 + /** 51 + * Loads each enabled integration package from config, validates as 52 + * CoopIntegrationPlugin, and returns manifest map and list of plugin entries 53 + * (packageSpec + integrationId) for later signal loading. 54 + * Skips entries with enabled: false. Throws on invalid plugin or duplicate id. 55 + * Package specs in config (e.g. "../coop-integration-example") are resolved 56 + * relative to the directory containing the config file, so the server works 57 + * whether started from server/ or repo root. 58 + */ 59 + /* eslint-disable complexity -- logo path resolution and fallbacks add branches; kept in one place for clarity. */ 60 + export function loadPlugins( 61 + config: CoopIntegrationsConfig, 62 + configPath: string, 63 + ): LoadPluginsResult { 64 + const require = createRequire(path.join(path.dirname(configPath), 'package.json')); 65 + const map = new Map<string, IntegrationManifestEntry>(); 66 + const pluginEntries: PluginEntry[] = []; 67 + const pluginLogoPaths = new Map<string, string>(); 68 + const pluginLogoWithBackgroundPaths = new Map<string, string>(); 69 + 70 + for (const entry of config.integrations) { 71 + if (entry.enabled === false) continue; 72 + const packageSpec = entry.package; 73 + let plugin: unknown; 74 + try { 75 + // eslint-disable-next-line security/detect-non-literal-require -- package spec from integrations config (deployment-controlled), not user input 76 + plugin = require(packageSpec); 77 + } catch (err: unknown) { 78 + throw new Error( 79 + `Failed to load integration package "${packageSpec}": ${err instanceof Error ? err.message : String(err)}`, 80 + ); 81 + } 82 + const resolved: unknown = 83 + (plugin as { default?: unknown }).default ?? plugin; 84 + if (!isCoopIntegrationPlugin(resolved)) { 85 + throw new Error( 86 + `Integration package "${packageSpec}" does not export a valid CoopIntegrationPlugin (manifest with id, name, version, requiresConfig).`, 87 + ); 88 + } 89 + const manifest = resolved.manifest; 90 + const id = manifest.id; 91 + if (map.has(id)) { 92 + throw new Error( 93 + `Duplicate integration id "${id}" from package "${packageSpec}".`, 94 + ); 95 + } 96 + if (manifest.modelCard != null) { 97 + assertModelCardHasRequiredSections(manifest.modelCard as ModelCard); 98 + } 99 + 100 + let logoUrl = manifest.logoUrl; 101 + let logoWithBackgroundUrl = manifest.logoWithBackgroundUrl; 102 + const logoPath = (manifest as { logoPath?: string }).logoPath; 103 + const logoWithBackgroundPath = (manifest as { logoWithBackgroundPath?: string }).logoWithBackgroundPath; 104 + const entryPath = require.resolve(packageSpec); 105 + const packageRoot = findPackageRoot(path.dirname(entryPath)); 106 + const packageRootResolved = path.resolve(packageRoot); 107 + 108 + const resolveLogoPath = ( 109 + relPath: string, 110 + ): { fullPathResolved: string; found: boolean; pathToUse: string } => { 111 + const normalized = path.normalize(relPath).replace(/^(\.\.(\/|\\))+/, ''); 112 + const fullPath = path.join(packageRoot, normalized); 113 + const fullPathResolved = path.resolve(fullPath); 114 + if ( 115 + !fullPathResolved.startsWith(packageRootResolved) || 116 + path.relative(packageRootResolved, fullPathResolved).startsWith('..') 117 + ) { 118 + throw new Error( 119 + `Integration "${id}" logo path must be inside the package: ${relPath}`, 120 + ); 121 + } 122 + // eslint-disable-next-line security/detect-non-literal-fs-filename -- fullPathResolved is under package root from manifest path 123 + let found = fs.existsSync(fullPathResolved); 124 + let pathToUse = fullPathResolved; 125 + if (!found) { 126 + const altPath = path.join(packageRoot, relPath); 127 + // eslint-disable-next-line security/detect-non-literal-fs-filename -- altPath under package root from manifest 128 + if (fs.existsSync(altPath)) { 129 + pathToUse = path.resolve(altPath); 130 + found = true; 131 + } 132 + } 133 + return { fullPathResolved, found, pathToUse }; 134 + }; 135 + 136 + // logoPath → plain logo (no background), served at /id as logoUrl — used on integrations page. 137 + // logoWithBackgroundPath → logo with background, served at /id/with-background as logoWithBackgroundUrl — used in signal modals. 138 + if (logoPath != null && logoPath.length > 0) { 139 + const logoUrlPath = `${INTEGRATION_LOGOS_PATH_PREFIX}/${id}`; 140 + const { fullPathResolved, found, pathToUse } = resolveLogoPath(logoPath); 141 + if (found) { 142 + pluginLogoPaths.set(id, pathToUse); 143 + logoUrl = logoUrlPath; 144 + if (logoWithBackgroundUrl == null && logoWithBackgroundPath == null) 145 + logoWithBackgroundUrl = logoUrlPath; 146 + } else { 147 + logoUrl = logoUrlPath; 148 + if (logoWithBackgroundUrl == null && logoWithBackgroundPath == null) 149 + logoWithBackgroundUrl = logoUrlPath; 150 + // eslint-disable-next-line no-console -- plugin load; SafeTracer may not be available yet. 151 + console.warn( 152 + `[integrations] Logo file not found for "${id}": tried ${fullPathResolved} (manifest.logoPath: ${logoPath})`, 153 + ); 154 + } 155 + } 156 + if (logoWithBackgroundPath != null && logoWithBackgroundPath.length > 0) { 157 + const withBgUrlPath = `${INTEGRATION_LOGOS_PATH_PREFIX}/${id}/with-background`; 158 + const { fullPathResolved, found, pathToUse } = resolveLogoPath(logoWithBackgroundPath); 159 + if (found) { 160 + pluginLogoWithBackgroundPaths.set(id, pathToUse); 161 + logoWithBackgroundUrl = withBgUrlPath; 162 + } else { 163 + logoWithBackgroundUrl = withBgUrlPath; 164 + // eslint-disable-next-line no-console -- plugin load; SafeTracer may not be available yet. 165 + console.warn( 166 + `[integrations] Logo-with-background file not found for "${id}": tried ${fullPathResolved} (manifest.logoWithBackgroundPath: ${logoWithBackgroundPath})`, 167 + ); 168 + } 169 + } 170 + 171 + const serverEntry: IntegrationManifestEntry = { 172 + title: manifest.name, 173 + docsUrl: manifest.docsUrl ?? '', 174 + requiresConfig: manifest.requiresConfig, 175 + modelCard: manifest.modelCard as ModelCard, 176 + modelCardLearnMoreUrl: (manifest as { modelCardLearnMoreUrl?: string }) 177 + .modelCardLearnMoreUrl, 178 + logoUrl, 179 + logoWithBackgroundUrl, 180 + }; 181 + // Plugin manifest may omit modelCard; we require it for display. 182 + if ((serverEntry as { modelCard?: ModelCard }).modelCard == null) { 183 + throw new Error( 184 + `Integration "${id}" (${packageSpec}) must provide a modelCard with at least "modelDetails" and "technicalIntegration" sections.`, 185 + ); 186 + } 187 + map.set(id, serverEntry); 188 + pluginEntries.push({ packageSpec, integrationId: id }); 189 + } 190 + 191 + return { manifests: map, pluginEntries, pluginLogoPaths, pluginLogoWithBackgroundPaths }; 192 + }
+2 -6
server/services/moderationConfigService/types/rules.ts
··· 46 46 args: SignalArgsByType['AGGREGATION']; 47 47 } 48 48 | { 49 - type: Exclude<SignalType, 'AGGREGATION'>; 50 - // Our GQL input validation code assumes that, for all signals besides 51 - // aggregation, the args must be undefined, so we want TS to give us a 52 - // type error here if that ever becomes not true. Then, we can update the 53 - // GQL validation code and any downstream code if the args for these other 54 - // signals change. 49 + // Exclude<SignalType, 'AGGREGATION'> | string to support plugin signal types (e.g. RANDOM_SIGNAL_SELECTION) 50 + type: Exclude<SignalType, 'AGGREGATION'> | string; 55 51 args?: Satisfies< 56 52 SignalArgsByType[Exclude<SignalType, 'AGGREGATION'>], 57 53 undefined
+14
server/services/signalAuthService/dbTypes.ts
··· 1 1 import { type ColumnType } from 'kysely'; 2 2 3 + /** JSONB config blob; shape defined per integration (see @roostorg/types StoredIntegrationConfigPayload). */ 4 + export type IntegrationConfigRow = { 5 + org_id: string; 6 + integration_id: string; 7 + config: ColumnType< 8 + Record<string, unknown>, 9 + Record<string, unknown> | string, 10 + Record<string, unknown> | string 11 + >; 12 + created_at: ColumnType<Date, never, never>; 13 + updated_at: ColumnType<Date, never, never>; 14 + }; 15 + 3 16 export type SignalAuthServicePg = { 17 + 'signal_auth_service.integration_configs': IntegrationConfigRow; 4 18 'signal_auth_service.google_content_safety_configs': { 5 19 org_id: string; 6 20 api_key: string;
+75
server/services/signalAuthService/signalAuthService.ts
··· 89 89 */ 90 90 class SignalAuthService { 91 91 private implementations: CredentialImplementations; 92 + private pg: Kysely<SignalAuthServicePg>; 92 93 93 94 constructor(pg: Kysely<SignalAuthServicePg>) { 95 + this.pg = pg; 94 96 this.implementations = makeImplementations(pg); 95 97 } 96 98 ··· 114 116 orgId: string, 115 117 ): Promise<void> { 116 118 await this.implementations[integration].delete(orgId); 119 + } 120 + 121 + /** 122 + * Get stored config by string integration id. For built-ins uses legacy tables; 123 + * for plugin integrations uses the generic integration_configs table. 124 + */ 125 + async getByIntegrationId( 126 + integrationId: string, 127 + orgId: string, 128 + ): Promise<Record<string, unknown> | undefined> { 129 + if (integrationId === Integration.GOOGLE_CONTENT_SAFETY_API) { 130 + const c = await this.get(Integration.GOOGLE_CONTENT_SAFETY_API, orgId); 131 + return c != null ? { apiKey: c.apiKey } : undefined; 132 + } 133 + if (integrationId === Integration.OPEN_AI) { 134 + const c = await this.get(Integration.OPEN_AI, orgId); 135 + return c != null ? { apiKey: c.apiKey } : undefined; 136 + } 137 + if (integrationId === Integration.ZENTROPI) { 138 + const c = await this.get(Integration.ZENTROPI, orgId); 139 + return c != null ? { apiKey: c.apiKey, labelerVersions: c.labelerVersions } : undefined; 140 + } 141 + const row = await this.pg 142 + .selectFrom('signal_auth_service.integration_configs') 143 + .select(['config']) 144 + .where('org_id', '=', orgId) 145 + .where('integration_id', '=', integrationId) 146 + .executeTakeFirst(); 147 + if (row == null) return undefined; 148 + const config = row.config; 149 + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition, @typescript-eslint/no-unnecessary-type-assertion -- DB JSON type can be null; cast for API shape 150 + return config != null ? (config as Record<string, unknown>) : undefined; 151 + } 152 + 153 + /** 154 + * Set stored config by string integration id. For built-ins uses legacy tables; 155 + * for plugin integrations uses the generic integration_configs table. 156 + */ 157 + async setByIntegrationId( 158 + integrationId: string, 159 + orgId: string, 160 + config: Record<string, unknown>, 161 + ): Promise<Record<string, unknown>> { 162 + if (integrationId === Integration.GOOGLE_CONTENT_SAFETY_API) { 163 + const apiKey = typeof config.apiKey === 'string' ? config.apiKey : ''; 164 + await this.set(Integration.GOOGLE_CONTENT_SAFETY_API, orgId, { apiKey }); 165 + return { apiKey }; 166 + } 167 + if (integrationId === Integration.OPEN_AI) { 168 + const apiKey = typeof config.apiKey === 'string' ? config.apiKey : ''; 169 + await this.set(Integration.OPEN_AI, orgId, { apiKey }); 170 + return { apiKey }; 171 + } 172 + if (integrationId === Integration.ZENTROPI) { 173 + const apiKey = typeof config.apiKey === 'string' ? config.apiKey : ''; 174 + const labelerVersions = Array.isArray(config.labelerVersions) 175 + ? (config.labelerVersions as ZentropiLabelerVersion[]) 176 + : []; 177 + await this.set(Integration.ZENTROPI, orgId, { apiKey, labelerVersions }); 178 + return { apiKey, labelerVersions }; 179 + } 180 + await this.pg 181 + .insertInto('signal_auth_service.integration_configs') 182 + .values({ 183 + org_id: orgId, 184 + integration_id: integrationId, 185 + config, 186 + }) 187 + .onConflict((oc) => 188 + oc.columns(['org_id', 'integration_id']).doUpdateSet({ config }), 189 + ) 190 + .execute(); 191 + return config; 117 192 } 118 193 } 119 194
+59 -26
server/services/signalsService/SignalsService.ts
··· 8 8 import { CoopError, ErrorType, makeNotFoundError } from '../../utils/errors.js'; 9 9 import { __throw, assertUnreachable } from '../../utils/misc.js'; 10 10 import { type CollapseCases } from '../../utils/typescript-types.js'; 11 + import { getIntegrationRegistry } from '../integrationRegistry/index.js'; 11 12 import { instantiateBuiltInSignals } from './helpers/instantiateBuiltInSignals.js'; 13 + import { loadPluginSignals } from './helpers/loadPluginSignals.js'; 12 14 import { makeCachedCredentialGetters } from './helpers/makeCachedCredentialsGetters.js'; 13 15 import { makeCachedFetchers } from './helpers/makeCachedFetchers.js'; 14 16 import { 15 17 signalIsExternal, 16 18 type SignalId, 17 19 type SignalInputType, 20 + type SignalOutputType, 18 21 type SignalType, 19 22 } from './index.js'; 20 23 import type UnusedCustomSignal from './signals/CustomSignal.js'; ··· 55 58 * itself. 56 59 */ 57 60 export type Signal = Simplify< 58 - Pick<SignalBase<SignalInputType>, (typeof publicSignalProps)[number]> 61 + Pick< 62 + SignalBase< 63 + SignalInputType, 64 + SignalOutputType, 65 + unknown, 66 + SignalType | string 67 + >, 68 + (typeof publicSignalProps)[number] 69 + > 59 70 >; 60 71 61 72 /** ··· 100 111 [K in SignalType]: ReturnType<SignalClassByType[K]['run']>; 101 112 }; 102 113 114 + /** All signals by type: built-in + plugin. Used for lookup and getSignalsForOrg. */ 115 + type SignalsByType = Record< 116 + string, 117 + SignalBase<SignalInputType, SignalOutputType, unknown, SignalType | string> 118 + >; 119 + 103 120 export class SignalsService { 104 121 public readonly close: () => Promise<void>; 105 122 106 123 private readonly builtInSignalsByType: BuiltInSignalsByType; 124 + private readonly signalsByType: SignalsByType; 107 125 108 126 constructor( 109 127 private readonly tracer: Dependencies['Tracer'], ··· 134 152 this.hmaService, 135 153 ); 136 154 155 + const pluginEntries = getIntegrationRegistry().getPluginEntries(); 156 + const pluginSignals = loadPluginSignals(pluginEntries, signalAuthService); 157 + const builtInIds = new Set(Object.keys(this.builtInSignalsByType)); 158 + const collision = Object.keys(pluginSignals).find((id) => builtInIds.has(id)); 159 + if (collision != null) { 160 + throw new Error( 161 + `Plugin signal type "${collision}" collides with a built-in signal; use a different signalTypeId.`, 162 + ); 163 + } 164 + this.signalsByType = { 165 + ...this.builtInSignalsByType, 166 + ...pluginSignals, 167 + }; 168 + 137 169 this.close = async function () { 170 + await cachedCredentialGetters.close(); 138 171 await Promise.all( 139 - [ 140 - ...Object.values(cachedCredentialGetters), 141 - ...Object.values(cachedFetchers), 142 - ].map(async (it) => it.close()), 172 + Object.values(cachedFetchers).map(async (it) => it.close()), 143 173 ); 144 174 }; 145 175 } ··· 161 191 }): Promise<Signal[]> { 162 192 const { orgId, externalOnly = true } = opts; 163 193 164 - const builtInSignals = Object.values(this.builtInSignalsByType).filter( 165 - (signal) => isSignalEnabledForOrg(signal.id, orgId), 194 + const allSignals = Object.values(this.signalsByType).filter((signal) => 195 + isSignalEnabledForOrg(signal.id, orgId), 166 196 ); 167 197 168 - const finalBuiltInSignals = externalOnly 169 - ? builtInSignals.filter((it) => signalIsExternal(it.id)) 170 - : builtInSignals; 198 + const finalSignals = externalOnly 199 + ? allSignals.filter((it) => signalIsExternal(it.id)) 200 + : allSignals; 171 201 172 - return finalBuiltInSignals.map((it) => 173 - this.#signalInstanceToPublicSignal(it), 174 - ); 202 + return finalSignals.map((it) => this.#signalInstanceToPublicSignal(it)); 175 203 } 176 204 177 205 async #getSignalInstance<T extends SignalType>( 178 206 ref: SignalReference<T>, 179 - ): Promise<SignalBase<SignalInputType> | undefined> { 207 + ): Promise< 208 + | SignalBase< 209 + SignalInputType, 210 + SignalOutputType, 211 + unknown, 212 + SignalType | string 213 + > 214 + | undefined 215 + > { 180 216 const { signalId } = ref; 181 217 182 - // In this switch, we don't have assertUnreachable in the default case, but 183 - // TS will give an error if the the potential values that are left in 184 - // `signalId.type` aren't all usable to index into builtinSignalsByType 185 - // eslint-disable-next-line switch-statement/require-appropriate-default-case 186 - switch (signalId.type) { 187 - case 'CUSTOM': 188 - throw new Error('not implemented'); 189 - default: 190 - // For some reason, TS won't narrow the type based on the previous 191 - // case expressions, so we explicitly narrow it here. 192 - return this.builtInSignalsByType[signalId.type as Exclude<T, 'CUSTOM'>]; 218 + if (signalId.type === 'CUSTOM') { 219 + throw new Error('not implemented'); 193 220 } 221 + 222 + return this.signalsByType[signalId.type]; 194 223 } 195 224 196 225 public async getSignal<T extends SignalType>(ref: SignalReference<T>) { ··· 302 331 >; 303 332 } 304 333 305 - #signalInstanceToPublicSignal(it: ReadonlyDeep<SignalBase<SignalInputType>>) { 334 + #signalInstanceToPublicSignal( 335 + it: ReadonlyDeep< 336 + SignalBase<SignalInputType, SignalOutputType, unknown, SignalType | string> 337 + >, 338 + ) { 306 339 // This used to be implemented as simply `safePick(it, publicSignalProps)`, 307 340 // but we found that this is such a hot path that lodash was adding very 308 341 // noticeable overhead -- `_.pick` calls all kinds of internal lodash
+91
server/services/signalsService/helpers/loadPluginSignals.ts
··· 1 + /** 2 + * Loads signal implementations from integration plugins and wraps them in 3 + * PluginSignalAdapter so they can be registered and used in rules. 4 + */ 5 + 6 + import { createRequire } from 'module'; 7 + import path from 'path'; 8 + 9 + import { isCoopIntegrationPlugin } from '@roostorg/types'; 10 + import PluginSignalAdapter, { 11 + type PluginSignalDescriptor, 12 + } from '../signals/PluginSignalAdapter.js'; 13 + import type { 14 + SignalBase, 15 + SignalInputType, 16 + } from '../signals/SignalBase.js'; 17 + import type { PluginEntry } from '../../integrationRegistry/loadPlugins.js'; 18 + import type { SignalAuthService } from '../../signalAuthService/index.js'; 19 + import type { SignalOutputType } from '../types/SignalOutputType.js'; 20 + 21 + export type PluginSignalsByType = Record< 22 + string, 23 + SignalBase<SignalInputType, SignalOutputType, unknown, string> 24 + >; 25 + 26 + /** 27 + * For each plugin entry, requires the package, calls createSignals(context) if 28 + * present, wraps each returned descriptor in PluginSignalAdapter, and returns 29 + * a map of signalTypeId -> adapter. Uses getByIntegrationId for credential lookup. 30 + */ 31 + export function loadPluginSignals( 32 + pluginEntries: readonly PluginEntry[], 33 + signalAuthService: SignalAuthService, 34 + ): PluginSignalsByType { 35 + const require = createRequire(path.join(process.cwd(), 'package.json')); 36 + const byType: PluginSignalsByType = {}; 37 + 38 + for (const { packageSpec, integrationId } of pluginEntries) { 39 + let plugin: unknown; 40 + try { 41 + // eslint-disable-next-line security/detect-non-literal-require -- package spec from integrations config (deployment-controlled), not user input 42 + plugin = require(packageSpec); 43 + } catch (err: unknown) { 44 + throw new Error( 45 + `Failed to load integration package "${packageSpec}" for signals: ${err instanceof Error ? err.message : String(err)}`, 46 + ); 47 + } 48 + const resolved: unknown = 49 + (plugin as { default?: unknown }).default ?? plugin; 50 + if (!isCoopIntegrationPlugin(resolved)) continue; 51 + const createSignals = (resolved as { createSignals?: (ctx: unknown) => unknown[] }).createSignals; 52 + if (typeof createSignals !== 'function') continue; 53 + 54 + const getCredential = async (orgId: string) => 55 + signalAuthService.getByIntegrationId(integrationId, orgId); 56 + const context = { integrationId, getCredential }; 57 + let signals: unknown[]; 58 + try { 59 + const raw = createSignals(context); 60 + signals = Array.isArray(raw) ? raw : []; 61 + } catch (err: unknown) { 62 + throw new Error( 63 + `Plugin "${packageSpec}" createSignals failed: ${err instanceof Error ? err.message : String(err)}`, 64 + ); 65 + } 66 + for (const item of signals) { 67 + if ( 68 + item == null || 69 + typeof item !== 'object' || 70 + !('signalTypeId' in item) || 71 + !('signal' in item) 72 + ) 73 + continue; 74 + const { signalTypeId, signal: descriptor } = item as { 75 + signalTypeId: string; 76 + signal: unknown; 77 + }; 78 + if (typeof signalTypeId !== 'string' || descriptor == null) continue; 79 + if (signalTypeId in byType) { 80 + throw new Error( 81 + `Duplicate plugin signal type "${signalTypeId}" from package "${packageSpec}".`, 82 + ); 83 + } 84 + byType[signalTypeId] = new PluginSignalAdapter( 85 + descriptor as PluginSignalDescriptor, 86 + ); 87 + } 88 + } 89 + 90 + return byType; 91 + }
+37 -2
server/services/signalsService/helpers/makeCachedCredentialsGetters.ts
··· 2 2 import { cached } from '../../../utils/caching.js'; 3 3 import { type ConfigurableIntegration } from '../../signalAuthService/signalAuthService.js'; 4 4 5 + type CredentialCache = { 6 + (orgId: string): Promise<Record<string, unknown> | undefined>; 7 + close(): Promise<void>; 8 + }; 9 + 5 10 export type CredentialGetters = ReturnType<typeof makeCachedCredentialGetters>; 6 11 7 12 /** 8 13 * Returns a set of functions that can be used for looking up an org's stored 9 14 * API keys for a given third-party service, which is needed when running 10 - * signals that connect to that service. 15 + * signals that connect to that service. Also provides getForIntegrationId for 16 + * plugin integrations (any string id). Call close() to dispose all caches. 11 17 */ 12 18 export function makeCachedCredentialGetters( 13 19 signalAuthService: Dependencies['SignalAuthService'], ··· 21 27 directives: { freshUntilAge: 600 }, 22 28 }); 23 29 24 - return { 30 + // Create built-in caches once so close() disposes the same instances in use. 31 + const builtInCaches = { 25 32 GOOGLE_CONTENT_SAFETY_API: getApiCredentialForIntegration( 26 33 'GOOGLE_CONTENT_SAFETY_API', 27 34 ), 28 35 OPEN_AI: getApiCredentialForIntegration('OPEN_AI'), 29 36 ZENTROPI: getApiCredentialForIntegration('ZENTROPI'), 37 + }; 38 + 39 + const cacheByIntegrationId = new Map<string, CredentialCache>(); 40 + 41 + function getForIntegrationId(integrationId: string): CredentialCache { 42 + let c = cacheByIntegrationId.get(integrationId); 43 + if (c == null) { 44 + c = cached({ 45 + producer: async (orgId: string) => 46 + signalAuthService.getByIntegrationId(integrationId, orgId), 47 + directives: { freshUntilAge: 600 }, 48 + }); 49 + cacheByIntegrationId.set(integrationId, c); 50 + } 51 + return c; 52 + } 53 + 54 + async function close(): Promise<void> { 55 + await Promise.all([ 56 + ...Object.values(builtInCaches).map(async (c) => c.close()), 57 + ...Array.from(cacheByIntegrationId.values(), async (c) => c.close()), 58 + ]); 59 + } 60 + 61 + return { 62 + ...builtInCaches, 63 + getForIntegrationId, 64 + close, 30 65 }; 31 66 }
+141
server/services/signalsService/signals/PluginSignalAdapter.ts
··· 1 + /** 2 + * Adapts a plugin's PluginSignalDescriptor to the server's SignalBase so plugin 3 + * signals can be registered and used in routing/enforcement rules. 4 + */ 5 + 6 + import type { SignalSubcategory } from '@roostorg/types'; 7 + import { type ReadonlyDeep } from 'type-fest'; 8 + 9 + import { type Language } from '../../../utils/language.js'; 10 + import SignalBase, { 11 + type SignalDisabledInfo, 12 + type SignalErrorResult, 13 + type SignalInput, 14 + type SignalInputType, 15 + type SignalResult, 16 + } from './SignalBase.js'; 17 + import { type SignalOutputType } from '../types/SignalOutputType.js'; 18 + import { type SignalPricingStructure } from '../types/SignalPricingStructure.js'; 19 + 20 + /** Minimal descriptor shape from a plugin; matches @roostorg/types PluginSignalDescriptor. */ 21 + export type PluginSignalDescriptor = Readonly<{ 22 + id: { type: string }; 23 + displayName: string; 24 + description: string; 25 + docsUrl: string | null; 26 + recommendedThresholds: Readonly<{ 27 + highPrecisionThreshold: string | number; 28 + highRecallThreshold: string | number; 29 + }> | null; 30 + supportedLanguages: readonly string[] | 'ALL'; 31 + pricingStructure: { type: 'FREE' | 'SUBSCRIPTION' }; 32 + eligibleInputs: readonly string[]; 33 + outputType: Readonly<{ scalarType: string }>; 34 + getCost: () => number; 35 + run: (input: unknown) => Promise<unknown>; 36 + getDisabledInfo: (orgId: string) => Promise< 37 + | { disabled: false; disabledMessage?: string } 38 + | { disabled: true; disabledMessage: string } 39 + >; 40 + needsMatchingValues: boolean; 41 + eligibleSubcategories: ReadonlyArray<{ 42 + id: string; 43 + label: string; 44 + description?: string; 45 + childrenIds: readonly string[]; 46 + }>; 47 + needsActionPenalties: boolean; 48 + integration: string; 49 + allowedInAutomatedRules: boolean; 50 + }>; 51 + 52 + /** 53 + * Wraps a plugin-provided descriptor so it satisfies SignalBase and can be 54 + * registered in the signals map and used by the rule engine. 55 + */ 56 + export default class PluginSignalAdapter extends SignalBase< 57 + SignalInputType, 58 + SignalOutputType, 59 + unknown, 60 + string 61 + > { 62 + constructor( 63 + private readonly descriptor: ReadonlyDeep<PluginSignalDescriptor>, 64 + ) { 65 + super(); 66 + } 67 + 68 + override get id() { 69 + return this.descriptor.id; 70 + } 71 + 72 + override get displayName() { 73 + return this.descriptor.displayName; 74 + } 75 + 76 + override get description() { 77 + return this.descriptor.description; 78 + } 79 + 80 + override get docsUrl() { 81 + return this.descriptor.docsUrl; 82 + } 83 + 84 + override get recommendedThresholds() { 85 + return this.descriptor.recommendedThresholds; 86 + } 87 + 88 + override get supportedLanguages() { 89 + const L = this.descriptor.supportedLanguages; 90 + return (L === 'ALL' ? 'ALL' : L) as readonly Language[] | 'ALL'; 91 + } 92 + 93 + override get pricingStructure() { 94 + return this.descriptor.pricingStructure as unknown as SignalPricingStructure; 95 + } 96 + 97 + override get eligibleInputs() { 98 + return this.descriptor.eligibleInputs as readonly SignalInputType[]; 99 + } 100 + 101 + override get outputType() { 102 + return this.descriptor.outputType as SignalOutputType; 103 + } 104 + 105 + override getCost() { 106 + return this.descriptor.getCost(); 107 + } 108 + 109 + override async run( 110 + input: SignalInput, 111 + ): Promise<SignalResult<SignalOutputType> | SignalErrorResult> { 112 + const result = await this.descriptor.run(input); 113 + return result as SignalResult<SignalOutputType> | SignalErrorResult; 114 + } 115 + 116 + override async getDisabledInfo(orgId: string): Promise<SignalDisabledInfo> { 117 + return this.descriptor.getDisabledInfo(orgId) as Promise<SignalDisabledInfo>; 118 + } 119 + 120 + override get needsMatchingValues() { 121 + return this.descriptor.needsMatchingValues; 122 + } 123 + 124 + override get eligibleSubcategories() { 125 + return this.descriptor.eligibleSubcategories as unknown as ReadonlyDeep< 126 + SignalSubcategory[] 127 + >; 128 + } 129 + 130 + override get needsActionPenalties() { 131 + return this.descriptor.needsActionPenalties; 132 + } 133 + 134 + override get integration() { 135 + return this.descriptor.integration; 136 + } 137 + 138 + override get allowedInAutomatedRules() { 139 + return this.descriptor.allowedInAutomatedRules; 140 + } 141 + }
+10 -5
server/services/signalsService/signals/SignalBase.ts
··· 65 65 NeedsMatchingValues extends boolean = boolean, 66 66 NeedsActionPenalties extends boolean = boolean, 67 67 MatchingValue = T extends ScalarType ? ScalarTypeRuntimeType<T> : unknown, 68 - Type extends SignalType = SignalType, 68 + Type extends SignalType | string = SignalType, 69 69 > = { 70 70 value: T extends 'FULL_ITEM' 71 71 ? TaggedItemData ··· 88 88 // TODO: figure out a better, generalized way to capture signals' required params. 89 89 contextId?: string; 90 90 contentType?: string; 91 - args?: ReadonlyDeep<SignalArgsByType[Type]>; 91 + args?: ReadonlyDeep< 92 + Type extends SignalType ? SignalArgsByType[Type] : unknown 93 + >; 92 94 93 - runtimeArgs?: ReadonlyDeep<RuntimeSignalArgsByType[Type]>; 95 + runtimeArgs?: ReadonlyDeep< 96 + Type extends SignalType ? RuntimeSignalArgsByType[Type] : unknown 97 + >; 94 98 }; 95 99 96 100 // The result of running a signal can be: ··· 140 144 MatchingValue = Input extends ScalarType 141 145 ? ScalarTypeRuntimeType<Input> 142 146 : unknown, 143 - Type extends SignalType = SignalType, 147 + Type extends SignalType | string = SignalType, 144 148 > { 145 149 /** 146 150 * See {@link SignalId}. ··· 272 276 273 277 abstract get needsActionPenalties(): boolean; 274 278 275 - abstract get integration(): Integration | null; 279 + /** Built-in integration enum value, or integration id string for plugin signals. */ 280 + abstract get integration(): Integration | string | null; 276 281 277 282 /** 278 283 * Indicates whether this signal can be used in automated rules with actions.
+3 -4
server/services/signalsService/types/SignalId.ts
··· 30 30 export type InternalSignalId = { type: InternalSignalType }; 31 31 export type ExternalSignalId = 32 32 | { type: typeof SignalType.CUSTOM; id: NonEmptyString } 33 - | { 34 - type: Exclude<ExternalSignalType, typeof SignalType.CUSTOM>; 35 - }; 33 + | { type: Exclude<ExternalSignalType, typeof SignalType.CUSTOM> } 34 + | { type: string }; // plugin signal type ids (not in SignalType enum) 36 35 37 36 export const InternalSignalIdArbitrary = fc.record({ 38 37 type: InternalSignalTypeArbitrary, ··· 79 78 typeof it === 'object' && 80 79 it !== null && 81 80 'type' in it && 82 - Object.hasOwn(SignalType, it.type as string) && 81 + typeof (it as { type: unknown }).type === 'string' && 83 82 (it.type === SignalType.CUSTOM 84 83 ? 'id' in it && isNonEmptyString(it.id) 85 84 : true)
+4 -3
server/services/signalsService/types/SignalType.ts
··· 1 1 import { makeEnumLike } from '@roostorg/types'; 2 2 3 3 import { enumToArbitrary } from '../../../test/propertyTestingHelpers.js'; 4 - import { assertUnreachable } from '../../../utils/misc.js'; 5 4 import { Integration } from './Integration.js'; 6 5 7 6 // Internal signal types are always built-in signals. ··· 85 84 ); 86 85 export const ExternalSignalTypeArbitrary = enumToArbitrary(ExternalSignalType); 87 86 87 + /** Accepts SignalType or plugin signal type string (e.g. RANDOM_SIGNAL_SELECTION). */ 88 88 // eslint-disable-next-line complexity 89 - export function integrationForSignalType(type: SignalType) { 89 + export function integrationForSignalType(type: SignalType | string) { 90 90 switch (type) { 91 91 case 'GOOGLE_CONTENT_SAFETY_API_IMAGE': 92 92 return Integration.GOOGLE_CONTENT_SAFETY_API; ··· 120 120 case 'BENIGN_MODEL': 121 121 return null; 122 122 default: 123 - assertUnreachable(type); 123 + // Plugin signal types (e.g. RANDOM_SIGNAL_SELECTION): no built-in integration 124 + return null; 124 125 } 125 126 }
+2
types/integration.ts
··· 158 158 * If you provide logoPath and logoWithBackgroundPath, the server will serve the files at 159 159 * GET /api/v1/integration-logos/:integrationId and GET /api/v1/integration-logos/:integrationId/with-background 160 160 * and set logoUrl and logoWithBackgroundUrl accordingly. 161 + * Usage: logoUrl/logoPath = plain logo (no background), used on the integrations page; 162 + * logoWithBackgroundUrl/logoWithBackgroundPath = logo with background, used in signal modals. 161 163 * If you provide logoUrl and logoWithBackgroundUrl, the server will use those URLs directly. 162 164 * Prefered size: ~180x180px for logoUrl and ~120x120px for logoWithBackgroundUrl. 163 165 * Prefer a square or horizontal logo that scales well.