diff --git a/.betterer.results b/.betterer.results index 03accd2..84dbe30 100644 --- a/.betterer.results +++ b/.betterer.results @@ -149,10 +149,10 @@ exports[`No explicit any in client`] = { [138, 52, 3, "Unexpected any. Specify a different type.", "193409811"], [151, 52, 3, "Unexpected any. Specify a different type.", "193409811"] ], - "client/src/webpages/dashboard/rules/info/insights/RuleInsightsSamplesTable.tsx:1711845154": [ - [612, 25, 3, "Unexpected any. Specify a different type.", "193409811"], - [624, 32, 3, "Unexpected any. Specify a different type.", "193409811"], - [748, 11, 3, "Unexpected any. Specify a different type.", "193409811"] + "client/src/webpages/dashboard/rules/info/insights/RuleInsightsSamplesTable.tsx:1132615281": [ + [611, 25, 3, "Unexpected any. Specify a different type.", "193409811"], + [623, 32, 3, "Unexpected any. Specify a different type.", "193409811"], + [747, 11, 3, "Unexpected any. Specify a different type.", "193409811"] ], "client/src/webpages/dashboard/rules/rule_form/ReportingRuleForm.tsx:3391496803": [ [424, 38, 3, "Unexpected any. Specify a different type.", "193409811"], @@ -161,11 +161,11 @@ exports[`No explicit any in client`] = { "client/src/webpages/dashboard/rules/rule_form/ReportingRuleFormReducers.tsx:2408070124": [ [102, 27, 3, "Unexpected any. Specify a different type.", "193409811"] ], - "client/src/webpages/dashboard/rules/rule_form/RuleForm.tsx:2133305192": [ - [901, 45, 3, "Unexpected any. Specify a different type.", "193409811"], - [922, 45, 3, "Unexpected any. Specify a different type.", "193409811"], - [949, 42, 3, "Unexpected any. Specify a different type.", "193409811"], - [969, 42, 3, "Unexpected any. Specify a different type.", "193409811"] + "client/src/webpages/dashboard/rules/rule_form/RuleForm.tsx:1069542000": [ + [904, 45, 3, "Unexpected any. Specify a different type.", "193409811"], + [925, 45, 3, "Unexpected any. Specify a different type.", "193409811"], + [952, 42, 3, "Unexpected any. Specify a different type.", "193409811"], + [972, 42, 3, "Unexpected any. Specify a different type.", "193409811"] ], "client/src/webpages/dashboard/rules/rule_form/RuleFormReducers.tsx:195582564": [ [124, 27, 3, "Unexpected any. Specify a different type.", "193409811"] @@ -199,16 +199,16 @@ exports[`No explicit any in server`] = { [88, 37, 3, "Unexpected any. Specify a different type.", "193409811"], [89, 29, 3, "Unexpected any. Specify a different type.", "193409811"] ], - "server/condition_evaluator/conditionSet.ts:1666453120": [ + "server/condition_evaluator/conditionSet.ts:3044213798": [ [113, 42, 3, "Unexpected any. Specify a different type.", "193409811"], [139, 50, 3, "Unexpected any. Specify a different type.", "193409811"] ], - "server/condition_evaluator/leafCondition.ts:3547992583": [ + "server/condition_evaluator/leafCondition.ts:2941598758": [ [212, 47, 3, "Unexpected any. Specify a different type.", "193409811"] ], - "server/graphql/datasources/RuleApi.ts:1760036803": [ - [635, 31, 3, "Unexpected any. Specify a different type.", "193409811"], - [775, 28, 3, "Unexpected any. Specify a different type.", "193409811"] + "server/graphql/datasources/RuleApi.ts:1193935640": [ + [634, 31, 3, "Unexpected any. Specify a different type.", "193409811"], + [774, 28, 3, "Unexpected any. Specify a different type.", "193409811"] ], "server/graphql/datasources/UserApi.ts:3075210134": [ [52, 22, 3, "Unexpected any. Specify a different type.", "193409811"], @@ -217,17 +217,17 @@ exports[`No explicit any in server`] = { [78, 23, 3, "Unexpected any. Specify a different type.", "193409811"], [78, 31, 3, "Unexpected any. Specify a different type.", "193409811"] ], - "server/graphql/modules/apiKey.ts:1280430358": [ - [45, 13, 3, "Unexpected any. Specify a different type.", "193409811"], - [46, 18, 3, "Unexpected any. Specify a different type.", "193409811"], - [46, 27, 3, "Unexpected any. Specify a different type.", "193409811"], - [46, 41, 3, "Unexpected any. Specify a different type.", "193409811"], - [61, 16, 3, "Unexpected any. Specify a different type.", "193409811"], - [62, 24, 3, "Unexpected any. Specify a different type.", "193409811"], - [62, 40, 3, "Unexpected any. Specify a different type.", "193409811"], - [62, 54, 3, "Unexpected any. Specify a different type.", "193409811"] - ], - "server/graphql/modules/insights.ts:1817621512": [ + "server/graphql/modules/apiKey.ts:923927854": [ + [76, 13, 3, "Unexpected any. Specify a different type.", "193409811"], + [77, 18, 3, "Unexpected any. Specify a different type.", "193409811"], + [77, 27, 3, "Unexpected any. Specify a different type.", "193409811"], + [77, 41, 3, "Unexpected any. Specify a different type.", "193409811"], + [92, 16, 3, "Unexpected any. Specify a different type.", "193409811"], + [93, 24, 3, "Unexpected any. Specify a different type.", "193409811"], + [93, 40, 3, "Unexpected any. Specify a different type.", "193409811"], + [93, 54, 3, "Unexpected any. Specify a different type.", "193409811"] + ], + "server/graphql/modules/insights.ts:3497054019": [ [194, 11, 3, "Unexpected any. Specify a different type.", "193409811"], [224, 11, 3, "Unexpected any. Specify a different type.", "193409811"], [269, 11, 3, "Unexpected any. Specify a different type.", "193409811"], @@ -263,9 +263,9 @@ exports[`No explicit any in server`] = { [202, 32, 3, "Unexpected any. Specify a different type.", "193409811"], [202, 38, 3, "Unexpected any. Specify a different type.", "193409811"] ], - "server/routes/index.ts:266628659": [ - [13, 16, 3, "Unexpected any. Specify a different type.", "193409811"], - [13, 21, 3, "Unexpected any. Specify a different type.", "193409811"] + "server/routes/index.ts:1040057535": [ + [14, 16, 3, "Unexpected any. Specify a different type.", "193409811"], + [14, 21, 3, "Unexpected any. Specify a different type.", "193409811"] ], "server/routes/policies/PoliciesRoutes.ts:1959143587": [ [14, 6, 3, "Unexpected any. Specify a different type.", "193409811"] @@ -398,9 +398,9 @@ exports[`No Deprecated API usage`] = { [158, 29, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], [396, 23, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"] ], - "client/src/webpages/dashboard/rules/rule_form/RuleForm.tsx:2133305192": [ - [673, 52, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], - [1011, 50, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"] + "client/src/webpages/dashboard/rules/rule_form/RuleForm.tsx:1069542000": [ + [676, 52, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], + [1014, 50, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"] ], "client/src/webpages/dashboard/rules/rule_form/RuleFormCondition.tsx:453078395": [ [122, 25, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], @@ -435,33 +435,33 @@ exports[`No Deprecated API usage`] = { "client/src/webpages/dashboard/rules/rule_form/condition/signal/RuleFormConditionSignalArgs.tsx:2792044510": [ [7, 29, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"] ], - "client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModal.tsx:2596084458": [ + "client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModal.tsx:1236028107": [ [13, 14, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], [14, 27, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], - [18, 19, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], - [32, 59, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], - [37, 13, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], - [82, 20, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], - [96, 41, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], - [99, 35, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"] - ], - "client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalMenuItem.tsx:1243180955": [ - [12, 35, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], - [23, 42, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], - [40, 10, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], - [42, 20, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"] - ], - "client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalSignalDetailView.tsx:821193771": [ + [16, 19, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], + [29, 59, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], + [34, 13, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], + [79, 20, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], + [93, 41, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], + [96, 35, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"] + ], + "client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalMenuItem.tsx:1152066983": [ + [13, 35, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], + [34, 42, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], + [51, 10, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], + [53, 20, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"] + ], + "client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalSignalDetailView.tsx:1680940259": [ [15, 10, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], [18, 12, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], - [94, 39, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"] + [112, 39, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"] ], - "client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalSignalGallery.tsx:2104107710": [ + "client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalSignalGallery.tsx:550203540": [ [11, 14, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], [12, 27, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"], [13, 33, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"] ], - "client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalSubcategoryGallery.tsx:3258286357": [ + "client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalSubcategoryGallery.tsx:531331643": [ [12, 10, 10, "\\"CoreSignal\\" is deprecated: ", "2064075776"] ], "client/src/webpages/dashboard/rules/types.ts:4119145711": [ @@ -498,11 +498,11 @@ exports[`No counterproductive type annotations`] = { [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"], [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"] ], - "client/src/webpages/dashboard/rules/info/insights/RuleInsightsSamplesTable.tsx:1711845154": [ - [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"] + "client/src/webpages/dashboard/rules/info/insights/RuleInsightsSamplesTable.tsx:1132615281": [ + [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"] ], - "client/src/webpages/dashboard/rules/rule_form/RuleForm.tsx:2133305192": [ - [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"] + "client/src/webpages/dashboard/rules/rule_form/RuleForm.tsx:1069542000": [ + [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"] ], "client/src/webpages/dashboard/rules/rule_form/RuleFormReducers.tsx:195582564": [ [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,7 +670,7 @@ exports[`No new ant-design icon imports`] = { "client/src/webpages/dashboard/rules/info/insights/RuleInsightsSamplesPlayVideoButton.tsx:3799970987": [ [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"] ], - "client/src/webpages/dashboard/rules/info/insights/RuleInsightsSamplesTable.tsx:1711845154": [ + "client/src/webpages/dashboard/rules/info/insights/RuleInsightsSamplesTable.tsx:1132615281": [ [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"] ], "client/src/webpages/dashboard/rules/info/insights/RuleInsightsSamplesVideoModal.tsx:2468150091": [ @@ -682,7 +682,7 @@ exports[`No new ant-design icon imports`] = { "client/src/webpages/dashboard/rules/rule_form/ReportingRuleForm.tsx:3391496803": [ [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"] ], - "client/src/webpages/dashboard/rules/rule_form/RuleForm.tsx:2133305192": [ + "client/src/webpages/dashboard/rules/rule_form/RuleForm.tsx:1069542000": [ [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"] ], "client/src/webpages/dashboard/rules/rule_form/RuleFormCondition.tsx:453078395": [ @@ -700,16 +700,16 @@ exports[`No new ant-design icon imports`] = { "client/src/webpages/dashboard/rules/rule_form/condition/threshold/RuleFormConditionThreshold.tsx:2990530520": [ [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"] ], - "client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalMenuItem.tsx:1243180955": [ + "client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalMenuItem.tsx:1152066983": [ [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"] ], "client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalNoSearchResults.tsx:2700507085": [ [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"] ], - "client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalSignalGallery.tsx:2104107710": [ + "client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalSignalGallery.tsx:550203540": [ [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"] ], - "client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalSubcategoryGallery.tsx:3258286357": [ + "client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalSubcategoryGallery.tsx:531331643": [ [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"] ] }` @@ -808,10 +808,10 @@ exports[`No new line-icon imports`] = { [0, 0, 87, "\'@/icons/lni/Direction/chevron-down.svg\' import is restricted from being used by a pattern.", "3761457464"], [1, 0, 83, "\'@/icons/lni/Direction/chevron-up.svg\' import is restricted from being used by a pattern.", "1296196504"] ], - "client/src/webpages/dashboard/overview/Overview.tsx:4156638298": [ + "client/src/webpages/dashboard/overview/Overview.tsx:2095261868": [ [10, 0, 123, "\'@/icons\' import is restricted from being used.", "3974909531"] ], - "client/src/webpages/dashboard/overview/OverviewCard.tsx:3637648116": [ + "client/src/webpages/dashboard/overview/OverviewCard.tsx:1601864003": [ [8, 0, 61, "\'@/icons\' import is restricted from being used.", "8406751"], [9, 0, 85, "\'@/icons/lni/Direction/arrow-right.svg\' import is restricted from being used by a pattern.", "427462392"], [10, 0, 97, "\'@/icons/lni/Direction/arrows-horizontal.svg\' import is restricted from being used by a pattern.", "3504538040"] @@ -847,7 +847,7 @@ exports[`No new line-icon imports`] = { [0, 0, 88, "\'@/icons/lni/Web and Technology/copy-alt.svg\' import is restricted from being used by a pattern.", "654958816"], [1, 0, 90, "\'@/icons/lni/Web and Technology/trash-can.svg\' import is restricted from being used by a pattern.", "3521839680"] ], - "client/src/webpages/dashboard/rules/rule_form/RuleForm.tsx:2133305192": [ + "client/src/webpages/dashboard/rules/rule_form/RuleForm.tsx:1069542000": [ [2, 0, 88, "\'@/icons/lni/Web and Technology/copy-alt.svg\' import is restricted from being used by a pattern.", "654958816"], [3, 0, 90, "\'@/icons/lni/Web and Technology/trash-can.svg\' import is restricted from being used by a pattern.", "3521839680"] ] diff --git a/.devops/migrator/src/scripts/api-server-pg/2026.02.23T00.00.00.add_generic_integration_configs.sql b/.devops/migrator/src/scripts/api-server-pg/2026.02.23T00.00.00.add_generic_integration_configs.sql new file mode 100644 index 0000000..76e6aaa --- /dev/null +++ b/.devops/migrator/src/scripts/api-server-pg/2026.02.23T00.00.00.add_generic_integration_configs.sql @@ -0,0 +1,22 @@ +-- Generic integration configs table: one row per (org_id, integration_id). +-- Config is stored as JSONB so each integration can define its own credential/config +-- shape without new tables or migrations. The app serializes/deserializes using +-- the integration's manifest (e.g. credentialFields). + +CREATE TABLE signal_auth_service.integration_configs ( + org_id character varying(255) NOT NULL, + integration_id character varying(255) NOT NULL, + config JSONB NOT NULL DEFAULT '{}', + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL +); + +ALTER TABLE signal_auth_service.integration_configs OWNER TO postgres; + +ALTER TABLE ONLY signal_auth_service.integration_configs + ADD CONSTRAINT integration_configs_pkey PRIMARY KEY (org_id, integration_id); + +ALTER TABLE ONLY signal_auth_service.integration_configs + ADD CONSTRAINT integration_configs_org_id_fkey FOREIGN KEY (org_id) REFERENCES public.orgs(id) ON DELETE CASCADE; + +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).'; diff --git a/client/package-lock.json b/client/package-lock.json index 3827eed..de58c19 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -29,7 +29,7 @@ "@radix-ui/react-slider": "^1.2.0", "@radix-ui/react-switch": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.2", - "@roostorg/types": "^1.0.49", + "@roostorg/types": "^1.1.1", "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/forms": "^0.5.7", "@tailwindcss/typography": "^0.5.13", @@ -6077,9 +6077,9 @@ } }, "node_modules/@roostorg/types": { - "version": "1.0.49", - "resolved": "https://registry.npmjs.org/@roostorg/types/-/types-1.0.49.tgz", - "integrity": "sha512-yrilSnPzP/KPryazRQbufMuwfBTLnne08TdkZqUW2QObh6oHkyDGwShQSLkWqRzKJxo+VSFZlbHGXdVUeGS3LQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@roostorg/types/-/types-1.1.1.tgz", + "integrity": "sha512-NhPYlG27wAQaD7AzWkL3LJHu52/QfK8lt9QMahUx7fbRtB4fYILy4fGcLQvt45gNQANoU78evW1UJftAB0B89Q==", "license": "ISC", "dependencies": { "date-fns": "^2.29.3", diff --git a/client/package.json b/client/package.json index 49b0084..2fc6f9a 100644 --- a/client/package.json +++ b/client/package.json @@ -37,7 +37,7 @@ "@radix-ui/react-slider": "^1.2.0", "@radix-ui/react-switch": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.2", - "@roostorg/types": "^1.0.49", + "@roostorg/types": "^1.1.1", "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/forms": "^0.5.7", "@tailwindcss/typography": "^0.5.13", @@ -161,8 +161,7 @@ "**/*.test.(js|ts|tsx)" ], "moduleNameMapper": { - "^@/(.*)$": "/src/$1", - "^@roostorg/types(.*)$": "/../types/$1" + "^@/(.*)$": "/src/$1" } }, "proxy": "http://localhost:8080" diff --git a/client/src/graphql/generated.ts b/client/src/graphql/generated.ts index 5937b81..8115b34 100644 --- a/client/src/graphql/generated.ts +++ b/client/src/graphql/generated.ts @@ -401,7 +401,7 @@ export type GQLConditionInputSignalInput = { readonly id: Scalars['ID']; readonly name?: InputMaybe; readonly subcategory?: InputMaybe; - readonly type: GQLSignalType; + readonly type: Scalars['String']; }; export type GQLConditionMatchingValuesInput = { @@ -1259,6 +1259,7 @@ export type GQLIntegration = export type GQLIntegrationApiCredential = | GQLGoogleContentSafetyApiIntegrationApiCredential | GQLOpenAiIntegrationApiCredential + | GQLPluginIntegrationApiCredential | GQLZentropiIntegrationApiCredential; export type GQLIntegrationApiCredentialInput = { @@ -1270,7 +1271,14 @@ export type GQLIntegrationApiCredentialInput = { export type GQLIntegrationConfig = { readonly __typename: 'IntegrationConfig'; readonly apiCredential: GQLIntegrationApiCredential; - readonly name: GQLIntegration; + readonly docsUrl: Scalars['String']; + readonly logoUrl?: Maybe; + readonly logoWithBackgroundUrl?: Maybe; + readonly modelCard: GQLModelCard; + readonly modelCardLearnMoreUrl?: Maybe; + readonly name: Scalars['String']; + readonly requiresConfig: Scalars['Boolean']; + readonly title: Scalars['String']; }; export type GQLIntegrationConfigQueryResponse = @@ -1312,6 +1320,16 @@ export type GQLIntegrationEmptyInputCredentialsError = GQLError & { readonly type: ReadonlyArray; }; +export type GQLIntegrationMetadata = { + readonly __typename: 'IntegrationMetadata'; + readonly docsUrl: Scalars['String']; + readonly logoUrl?: Maybe; + readonly logoWithBackgroundUrl?: Maybe; + readonly name: Scalars['String']; + readonly requiresConfig: Scalars['Boolean']; + readonly title: Scalars['String']; +}; + export type GQLIntegrationNoInputCredentialsError = GQLError & { readonly __typename: 'IntegrationNoInputCredentialsError'; readonly detail?: Maybe; @@ -2094,6 +2112,34 @@ export const GQLMetricsTimeDivisionOptions = { export type GQLMetricsTimeDivisionOptions = (typeof GQLMetricsTimeDivisionOptions)[keyof typeof GQLMetricsTimeDivisionOptions]; +export type GQLModelCard = { + readonly __typename: 'ModelCard'; + readonly modelName: Scalars['String']; + readonly releaseDate?: Maybe; + readonly sections?: Maybe>; + readonly version: Scalars['String']; +}; + +export type GQLModelCardField = { + readonly __typename: 'ModelCardField'; + readonly label: Scalars['String']; + readonly value: Scalars['String']; +}; + +export type GQLModelCardSection = { + readonly __typename: 'ModelCardSection'; + readonly fields?: Maybe>; + readonly id: Scalars['String']; + readonly subsections?: Maybe>; + readonly title: Scalars['String']; +}; + +export type GQLModelCardSubsection = { + readonly __typename: 'ModelCardSubsection'; + readonly fields: ReadonlyArray; + readonly title: Scalars['String']; +}; + export type GQLModeratorSafetySettingsInput = { readonly moderatorSafetyBlurLevel: Scalars['Int']; readonly moderatorSafetyGrayscale: Scalars['Boolean']; @@ -2267,6 +2313,7 @@ export type GQLMutation = { readonly setModeratorSafetySettings?: Maybe; readonly setMrtChartConfigurationSettings?: Maybe; readonly setOrgDefaultSafetySettings?: Maybe; + readonly setPluginIntegrationConfig: GQLSetIntegrationConfigResponse; readonly signUp: GQLSignUpResponse; readonly submitManualReviewDecision: GQLSubmitDecisionResponse; readonly updateAccountInfo?: Maybe; @@ -2519,6 +2566,10 @@ export type GQLMutationSetOrgDefaultSafetySettingsArgs = { orgDefaultSafetySettings: GQLModeratorSafetySettingsInput; }; +export type GQLMutationSetPluginIntegrationConfigArgs = { + input: GQLSetPluginIntegrationConfigInput; +}; + export type GQLMutationSignUpArgs = { input: GQLSignUpInput; }; @@ -2968,6 +3019,11 @@ export type GQLPlaceBoundsInput = { readonly southwestCorner: GQLLatLngInput; }; +export type GQLPluginIntegrationApiCredential = { + readonly __typename: 'PluginIntegrationApiCredential'; + readonly credential: Scalars['JSONObject']; +}; + export type GQLPolicy = { readonly __typename: 'Policy'; readonly applyUserStrikeCountConfigToChildren?: Maybe; @@ -3036,6 +3092,7 @@ export type GQLQuery = { readonly allRuleInsights: GQLAllRuleInsights; readonly apiKey: Scalars['String']; readonly appealSettings?: Maybe; + readonly availableIntegrations: ReadonlyArray; readonly getCommentsForJob: ReadonlyArray; readonly getDecidedJob?: Maybe; readonly getDecidedJobFromJobId?: Maybe; @@ -3178,7 +3235,7 @@ export type GQLQueryHashBankByIdArgs = { }; export type GQLQueryIntegrationConfigArgs = { - name: GQLIntegration; + name: Scalars['String']; }; export type GQLQueryInviteUserTokenArgs = { @@ -3853,6 +3910,11 @@ export type GQLSetMrtChartConfigurationSettingsSuccessResponse = { readonly _?: Maybe; }; +export type GQLSetPluginIntegrationConfigInput = { + readonly credential: Scalars['JSONObject']; + readonly integrationId: Scalars['String']; +}; + export type GQLSetUserStrikeThresholdInput = { readonly actions: ReadonlyArray; readonly threshold: Scalars['Int']; @@ -3901,7 +3963,13 @@ export type GQLSignal = { readonly eligibleInputs: ReadonlyArray; readonly eligibleSubcategories: ReadonlyArray; readonly id: Scalars['ID']; - readonly integration?: Maybe; + readonly integration?: Maybe; + /** Logo URL for the integration. Null if not set or when signal has no integration. */ + readonly integrationLogoUrl?: Maybe; + /** Logo-with-background URL for the integration. Null if not set or when signal has no integration. */ + readonly integrationLogoWithBackgroundUrl?: Maybe; + /** Display name for the signal’s integration (from registry manifest). Null when signal has no integration. */ + readonly integrationTitle?: Maybe; readonly name: Scalars['String']; readonly outputType: GQLSignalOutputType; readonly pricingStructure: GQLSignalPricingStructure; @@ -3909,7 +3977,7 @@ export type GQLSignal = { readonly shouldPromptForMatchingValues: Scalars['Boolean']; readonly subcategory?: Maybe; readonly supportedLanguages: GQLSupportedLanguages; - readonly type: GQLSignalType; + readonly type: Scalars['String']; }; export type GQLSignalArgs = GQLAggregationSignalArgs; @@ -4005,7 +4073,7 @@ export const GQLSignalType = { export type GQLSignalType = (typeof GQLSignalType)[keyof typeof GQLSignalType]; export type GQLSignalWithScore = { readonly __typename: 'SignalWithScore'; - readonly integration?: Maybe; + readonly integration?: Maybe; readonly score: Scalars['String']; readonly signalName: Scalars['String']; readonly subcategory?: Maybe; @@ -5653,13 +5721,41 @@ export type GQLSetIntegrationConfigMutation = { readonly __typename: 'SetIntegrationConfigSuccessResponse'; readonly config: { readonly __typename: 'IntegrationConfig'; - readonly name: GQLIntegration; + readonly name: string; + }; + }; +}; + +export type GQLSetPluginIntegrationConfigMutationVariables = Exact<{ + input: GQLSetPluginIntegrationConfigInput; +}>; + +export type GQLSetPluginIntegrationConfigMutation = { + readonly __typename: 'Mutation'; + readonly setPluginIntegrationConfig: + | { + readonly __typename: 'IntegrationConfigTooManyCredentialsError'; + readonly title: string; + } + | { + readonly __typename: 'IntegrationEmptyInputCredentialsError'; + readonly title: string; + } + | { + readonly __typename: 'IntegrationNoInputCredentialsError'; + readonly title: string; + } + | { + readonly __typename: 'SetIntegrationConfigSuccessResponse'; + readonly config: { + readonly __typename: 'IntegrationConfig'; + readonly name: string; }; }; }; export type GQLIntegrationConfigQueryVariables = Exact<{ - name: GQLIntegration; + name: Scalars['String']; }>; export type GQLIntegrationConfigQuery = { @@ -5669,7 +5765,38 @@ export type GQLIntegrationConfigQuery = { readonly __typename: 'IntegrationConfigSuccessResult'; readonly config?: { readonly __typename: 'IntegrationConfig'; - readonly name: GQLIntegration; + readonly name: string; + readonly title: string; + readonly docsUrl: string; + readonly requiresConfig: boolean; + readonly logoUrl?: string | null; + readonly logoWithBackgroundUrl?: string | null; + readonly modelCardLearnMoreUrl?: string | null; + readonly modelCard: { + readonly __typename: 'ModelCard'; + readonly modelName: string; + readonly version: string; + readonly releaseDate?: string | null; + readonly sections?: ReadonlyArray<{ + readonly __typename: 'ModelCardSection'; + readonly id: string; + readonly title: string; + readonly subsections?: ReadonlyArray<{ + readonly __typename: 'ModelCardSubsection'; + readonly title: string; + readonly fields: ReadonlyArray<{ + readonly __typename: 'ModelCardField'; + readonly label: string; + readonly value: string; + }>; + }> | null; + readonly fields?: ReadonlyArray<{ + readonly __typename: 'ModelCardField'; + readonly label: string; + readonly value: string; + }> | null; + }> | null; + }; readonly apiCredential: | { readonly __typename: 'GoogleContentSafetyApiIntegrationApiCredential'; @@ -5679,6 +5806,10 @@ export type GQLIntegrationConfigQuery = { readonly __typename: 'OpenAiIntegrationApiCredential'; readonly apiKey: string; } + | { + readonly __typename: 'PluginIntegrationApiCredential'; + readonly credential: JsonObject; + } | { readonly __typename: 'ZentropiIntegrationApiCredential'; readonly apiKey: string; @@ -5704,11 +5835,28 @@ export type GQLMyIntegrationsQuery = { readonly __typename: 'Org'; readonly integrationConfigs: ReadonlyArray<{ readonly __typename: 'IntegrationConfig'; - readonly name: GQLIntegration; + readonly name: string; }>; } | null; }; +export type GQLAvailableIntegrationsQueryVariables = Exact<{ + [key: string]: never; +}>; + +export type GQLAvailableIntegrationsQuery = { + readonly __typename: 'Query'; + readonly availableIntegrations: ReadonlyArray<{ + readonly __typename: 'IntegrationMetadata'; + readonly name: string; + readonly title: string; + readonly docsUrl: string; + readonly requiresConfig: boolean; + readonly logoUrl?: string | null; + readonly logoWithBackgroundUrl?: string | null; + }>; +}; + export type GQLInvestigationItemTypesQueryVariables = Exact<{ [key: string]: never; }>; @@ -6429,7 +6577,7 @@ export type GQLInvestigationItemsQuery = { readonly signal?: { readonly __typename: 'Signal'; readonly id: string; - readonly type: GQLSignalType; + readonly type: string; readonly name: string; readonly subcategory?: string | null; readonly args?: { @@ -6506,7 +6654,7 @@ export type GQLInvestigationItemsQuery = { readonly signal?: { readonly __typename: 'Signal'; readonly id: string; - readonly type: GQLSignalType; + readonly type: string; readonly name: string; readonly subcategory?: string | null; readonly args?: { @@ -16934,7 +17082,7 @@ export type GQLManualReviewQueueRoutingRulesQuery = { readonly signal?: { readonly __typename: 'Signal'; readonly id: string; - readonly type: GQLSignalType; + readonly type: string; readonly name: string; readonly subcategory?: string | null; readonly args?: { @@ -17010,7 +17158,7 @@ export type GQLManualReviewQueueRoutingRulesQuery = { readonly signal?: { readonly __typename: 'Signal'; readonly id: string; - readonly type: GQLSignalType; + readonly type: string; readonly name: string; readonly subcategory?: string | null; readonly args?: { @@ -17282,7 +17430,7 @@ export type GQLManualReviewQueueRoutingRulesQuery = { readonly signal?: { readonly __typename: 'Signal'; readonly id: string; - readonly type: GQLSignalType; + readonly type: string; readonly name: string; readonly subcategory?: string | null; readonly args?: { @@ -17358,7 +17506,7 @@ export type GQLManualReviewQueueRoutingRulesQuery = { readonly signal?: { readonly __typename: 'Signal'; readonly id: string; - readonly type: GQLSignalType; + readonly type: string; readonly name: string; readonly subcategory?: string | null; readonly args?: { @@ -17411,9 +17559,12 @@ export type GQLManualReviewQueueRoutingRulesQuery = { readonly signals: ReadonlyArray<{ readonly __typename: 'Signal'; readonly id: string; - readonly type: GQLSignalType; + readonly type: string; readonly name: string; - readonly integration?: GQLIntegration | null; + readonly integration?: string | null; + readonly integrationTitle?: string | null; + readonly integrationLogoUrl?: string | null; + readonly integrationLogoWithBackgroundUrl?: string | null; readonly docsUrl?: string | null; readonly description: string; readonly eligibleInputs: ReadonlyArray; @@ -18408,7 +18559,7 @@ export type GQLRulesQuery = { readonly signal?: { readonly __typename: 'Signal'; readonly id: string; - readonly type: GQLSignalType; + readonly type: string; readonly name: string; readonly subcategory?: string | null; readonly args?: { @@ -18484,7 +18635,7 @@ export type GQLRulesQuery = { readonly signal?: { readonly __typename: 'Signal'; readonly id: string; - readonly type: GQLSignalType; + readonly type: string; readonly name: string; readonly subcategory?: string | null; readonly args?: { @@ -18608,7 +18759,7 @@ export type GQLRulesQuery = { readonly signal?: { readonly __typename: 'Signal'; readonly id: string; - readonly type: GQLSignalType; + readonly type: string; readonly name: string; readonly subcategory?: string | null; readonly args?: { @@ -18684,7 +18835,7 @@ export type GQLRulesQuery = { readonly signal?: { readonly __typename: 'Signal'; readonly id: string; - readonly type: GQLSignalType; + readonly type: string; readonly name: string; readonly subcategory?: string | null; readonly args?: { @@ -18956,7 +19107,7 @@ export type GQLSpotTestRuleQuery = { readonly signal?: { readonly __typename: 'Signal'; readonly id: string; - readonly type: GQLSignalType; + readonly type: string; readonly name: string; readonly subcategory?: string | null; readonly args?: { @@ -19033,7 +19184,7 @@ export type GQLSpotTestRuleQuery = { readonly signal?: { readonly __typename: 'Signal'; readonly id: string; - readonly type: GQLSignalType; + readonly type: string; readonly name: string; readonly subcategory?: string | null; readonly args?: { @@ -19096,7 +19247,7 @@ export type GQLSpotTestRuleQuery = { readonly signalResults?: ReadonlyArray<{ readonly __typename: 'SignalWithScore'; readonly signalName: string; - readonly integration?: GQLIntegration | null; + readonly integration?: string | null; readonly subcategory?: string | null; readonly score: string; }> | null; @@ -19150,7 +19301,7 @@ export type GQLSampleReportingRuleExecutionResultFieldsFragment = { readonly signalResults?: ReadonlyArray<{ readonly __typename: 'SignalWithScore'; readonly signalName: string; - readonly integration?: GQLIntegration | null; + readonly integration?: string | null; readonly subcategory?: string | null; readonly score: string; }> | null; @@ -19229,7 +19380,7 @@ export type GQLReportingRuleInsightsCurrentVersionSamplesQuery = { readonly signalResults?: ReadonlyArray<{ readonly __typename: 'SignalWithScore'; readonly signalName: string; - readonly integration?: GQLIntegration | null; + readonly integration?: string | null; readonly subcategory?: string | null; readonly score: string; }> | null; @@ -19309,7 +19460,7 @@ export type GQLReportingRuleInsightsPriorVersionSamplesQuery = { readonly signalResults?: ReadonlyArray<{ readonly __typename: 'SignalWithScore'; readonly signalName: string; - readonly integration?: GQLIntegration | null; + readonly integration?: string | null; readonly subcategory?: string | null; readonly score: string; }> | null; @@ -19377,7 +19528,7 @@ export type GQLLeafConditionWithResultFieldsFragment = { readonly signal?: { readonly __typename: 'Signal'; readonly id: string; - readonly type: GQLSignalType; + readonly type: string; readonly name: string; readonly subcategory?: string | null; readonly args?: { readonly __typename: 'AggregationSignalArgs' } | null; @@ -19441,7 +19592,7 @@ export type GQLSampleRuleExecutionResultFieldsFragment = { readonly signalResults?: ReadonlyArray<{ readonly __typename: 'SignalWithScore'; readonly signalName: string; - readonly integration?: GQLIntegration | null; + readonly integration?: string | null; readonly subcategory?: string | null; readonly score: string; }> | null; @@ -19473,7 +19624,7 @@ export type GQLSampleRuleExecutionResultConditionResultFieldsFragment = { readonly signal?: { readonly __typename: 'Signal'; readonly id: string; - readonly type: GQLSignalType; + readonly type: string; readonly name: string; readonly subcategory?: string | null; readonly args?: { @@ -19550,7 +19701,7 @@ export type GQLSampleRuleExecutionResultConditionResultFieldsFragment = { readonly signal?: { readonly __typename: 'Signal'; readonly id: string; - readonly type: GQLSignalType; + readonly type: string; readonly name: string; readonly subcategory?: string | null; readonly args?: { @@ -19622,7 +19773,7 @@ export type GQLRuleInsightsTableAllSignalsQuery = { readonly signals: ReadonlyArray<{ readonly __typename: 'Signal'; readonly id: string; - readonly integration?: GQLIntegration | null; + readonly integration?: string | null; readonly eligibleSubcategories: ReadonlyArray<{ readonly __typename: 'SignalSubcategory'; readonly id: string; @@ -19657,7 +19808,7 @@ export type GQLRuleInsightsCurrentVersionSamplesQuery = { readonly signalResults?: ReadonlyArray<{ readonly __typename: 'SignalWithScore'; readonly signalName: string; - readonly integration?: GQLIntegration | null; + readonly integration?: string | null; readonly subcategory?: string | null; readonly score: string; }> | null; @@ -19729,7 +19880,7 @@ export type GQLRuleInsightsCurrentVersionSamplesQuery = { readonly signalResults?: ReadonlyArray<{ readonly __typename: 'SignalWithScore'; readonly signalName: string; - readonly integration?: GQLIntegration | null; + readonly integration?: string | null; readonly subcategory?: string | null; readonly score: string; }> | null; @@ -19764,7 +19915,7 @@ export type GQLRuleInsightsPriorVersionSamplesQuery = { readonly signalResults?: ReadonlyArray<{ readonly __typename: 'SignalWithScore'; readonly signalName: string; - readonly integration?: GQLIntegration | null; + readonly integration?: string | null; readonly subcategory?: string | null; readonly score: string; }> | null; @@ -19866,7 +20017,7 @@ export type GQLRuleInsightsPriorVersionSamplesQuery = { readonly signalResults?: ReadonlyArray<{ readonly __typename: 'SignalWithScore'; readonly signalName: string; - readonly integration?: GQLIntegration | null; + readonly integration?: string | null; readonly subcategory?: string | null; readonly score: string; }> | null; @@ -19920,7 +20071,7 @@ export type GQLGetFullResultForRuleQuery = { readonly signal?: { readonly __typename: 'Signal'; readonly id: string; - readonly type: GQLSignalType; + readonly type: string; readonly name: string; readonly subcategory?: string | null; readonly args?: { @@ -19997,7 +20148,7 @@ export type GQLGetFullResultForRuleQuery = { readonly signal?: { readonly __typename: 'Signal'; readonly id: string; - readonly type: GQLSignalType; + readonly type: string; readonly name: string; readonly subcategory?: string | null; readonly args?: { @@ -20060,7 +20211,7 @@ export type GQLGetFullResultForRuleQuery = { readonly signalResults?: ReadonlyArray<{ readonly __typename: 'SignalWithScore'; readonly signalName: string; - readonly integration?: GQLIntegration | null; + readonly integration?: string | null; readonly subcategory?: string | null; readonly score: string; }> | null; @@ -20147,7 +20298,7 @@ export type GQLReportingRuleFormRuleFieldsFragmentFragment = { readonly signal?: { readonly __typename: 'Signal'; readonly id: string; - readonly type: GQLSignalType; + readonly type: string; readonly name: string; readonly subcategory?: string | null; readonly args?: { @@ -20223,7 +20374,7 @@ export type GQLReportingRuleFormRuleFieldsFragmentFragment = { readonly signal?: { readonly __typename: 'Signal'; readonly id: string; - readonly type: GQLSignalType; + readonly type: string; readonly name: string; readonly subcategory?: string | null; readonly args?: { @@ -20596,7 +20747,7 @@ export type GQLReportingRuleQuery = { readonly signal?: { readonly __typename: 'Signal'; readonly id: string; - readonly type: GQLSignalType; + readonly type: string; readonly name: string; readonly subcategory?: string | null; readonly args?: { @@ -20672,7 +20823,7 @@ export type GQLReportingRuleQuery = { readonly signal?: { readonly __typename: 'Signal'; readonly id: string; - readonly type: GQLSignalType; + readonly type: string; readonly name: string; readonly subcategory?: string | null; readonly args?: { @@ -21006,9 +21157,12 @@ export type GQLReportingRuleFormOrgDataQuery = { readonly signals: ReadonlyArray<{ readonly __typename: 'Signal'; readonly id: string; - readonly type: GQLSignalType; + readonly type: string; readonly name: string; - readonly integration?: GQLIntegration | null; + readonly integration?: string | null; + readonly integrationTitle?: string | null; + readonly integrationLogoUrl?: string | null; + readonly integrationLogoWithBackgroundUrl?: string | null; readonly docsUrl?: string | null; readonly description: string; readonly eligibleInputs: ReadonlyArray; @@ -21369,9 +21523,12 @@ export type GQLItemTypeFragmentFragment = export type GQLSignalsFragmentFragment = { readonly __typename: 'Signal'; readonly id: string; - readonly type: GQLSignalType; + readonly type: string; readonly name: string; - readonly integration?: GQLIntegration | null; + readonly integration?: string | null; + readonly integrationTitle?: string | null; + readonly integrationLogoUrl?: string | null; + readonly integrationLogoWithBackgroundUrl?: string | null; readonly docsUrl?: string | null; readonly description: string; readonly eligibleInputs: ReadonlyArray; @@ -21449,7 +21606,7 @@ export type GQLLeafConditionFieldsFragment = { readonly signal?: { readonly __typename: 'Signal'; readonly id: string; - readonly type: GQLSignalType; + readonly type: string; readonly name: string; readonly subcategory?: string | null; readonly args?: { readonly __typename: 'AggregationSignalArgs' } | null; @@ -21531,7 +21688,7 @@ export type GQLConditionSetFieldsFragment = { readonly signal?: { readonly __typename: 'Signal'; readonly id: string; - readonly type: GQLSignalType; + readonly type: string; readonly name: string; readonly subcategory?: string | null; readonly args?: { @@ -21607,7 +21764,7 @@ export type GQLConditionSetFieldsFragment = { readonly signal?: { readonly __typename: 'Signal'; readonly id: string; - readonly type: GQLSignalType; + readonly type: string; readonly name: string; readonly subcategory?: string | null; readonly args?: { @@ -21706,7 +21863,7 @@ type GQLRuleFormRuleFieldsFragmentContentRuleFragment = { readonly signal?: { readonly __typename: 'Signal'; readonly id: string; - readonly type: GQLSignalType; + readonly type: string; readonly name: string; readonly subcategory?: string | null; readonly args?: { @@ -21782,7 +21939,7 @@ type GQLRuleFormRuleFieldsFragmentContentRuleFragment = { readonly signal?: { readonly __typename: 'Signal'; readonly id: string; - readonly type: GQLSignalType; + readonly type: string; readonly name: string; readonly subcategory?: string | null; readonly args?: { @@ -21976,7 +22133,7 @@ type GQLRuleFormRuleFieldsFragmentUserRuleFragment = { readonly signal?: { readonly __typename: 'Signal'; readonly id: string; - readonly type: GQLSignalType; + readonly type: string; readonly name: string; readonly subcategory?: string | null; readonly args?: { @@ -22052,7 +22209,7 @@ type GQLRuleFormRuleFieldsFragmentUserRuleFragment = { readonly signal?: { readonly __typename: 'Signal'; readonly id: string; - readonly type: GQLSignalType; + readonly type: string; readonly name: string; readonly subcategory?: string | null; readonly args?: { @@ -22433,7 +22590,7 @@ export type GQLRuleQuery = { readonly signal?: { readonly __typename: 'Signal'; readonly id: string; - readonly type: GQLSignalType; + readonly type: string; readonly name: string; readonly subcategory?: string | null; readonly args?: { @@ -22509,7 +22666,7 @@ export type GQLRuleQuery = { readonly signal?: { readonly __typename: 'Signal'; readonly id: string; - readonly type: GQLSignalType; + readonly type: string; readonly name: string; readonly subcategory?: string | null; readonly args?: { @@ -22702,7 +22859,7 @@ export type GQLRuleQuery = { readonly signal?: { readonly __typename: 'Signal'; readonly id: string; - readonly type: GQLSignalType; + readonly type: string; readonly name: string; readonly subcategory?: string | null; readonly args?: { @@ -22778,7 +22935,7 @@ export type GQLRuleQuery = { readonly signal?: { readonly __typename: 'Signal'; readonly id: string; - readonly type: GQLSignalType; + readonly type: string; readonly name: string; readonly subcategory?: string | null; readonly args?: { @@ -23309,9 +23466,12 @@ export type GQLContentRuleFormConfigQuery = { readonly signals: ReadonlyArray<{ readonly __typename: 'Signal'; readonly id: string; - readonly type: GQLSignalType; + readonly type: string; readonly name: string; - readonly integration?: GQLIntegration | null; + readonly integration?: string | null; + readonly integrationTitle?: string | null; + readonly integrationLogoUrl?: string | null; + readonly integrationLogoWithBackgroundUrl?: string | null; readonly docsUrl?: string | null; readonly description: string; readonly eligibleInputs: ReadonlyArray; @@ -24612,6 +24772,9 @@ export const GQLSignalsFragmentFragmentDoc = gql` type name integration + integrationTitle + integrationLogoUrl + integrationLogoWithBackgroundUrl docsUrl recommendedThresholds { highPrecisionThreshold @@ -27362,12 +27525,104 @@ export type GQLSetIntegrationConfigMutationOptions = Apollo.BaseMutationOptions< GQLSetIntegrationConfigMutation, GQLSetIntegrationConfigMutationVariables >; +export const GQLSetPluginIntegrationConfigDocument = gql` + mutation SetPluginIntegrationConfig( + $input: SetPluginIntegrationConfigInput! + ) { + setPluginIntegrationConfig(input: $input) { + ... on SetIntegrationConfigSuccessResponse { + config { + name + } + } + ... on IntegrationConfigTooManyCredentialsError { + title + } + ... on IntegrationNoInputCredentialsError { + title + } + ... on IntegrationEmptyInputCredentialsError { + title + } + } + } +`; +export type GQLSetPluginIntegrationConfigMutationFn = Apollo.MutationFunction< + GQLSetPluginIntegrationConfigMutation, + GQLSetPluginIntegrationConfigMutationVariables +>; + +/** + * __useGQLSetPluginIntegrationConfigMutation__ + * + * To run a mutation, you first call `useGQLSetPluginIntegrationConfigMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useGQLSetPluginIntegrationConfigMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @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; + * + * @example + * const [gqlSetPluginIntegrationConfigMutation, { data, loading, error }] = useGQLSetPluginIntegrationConfigMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useGQLSetPluginIntegrationConfigMutation( + baseOptions?: Apollo.MutationHookOptions< + GQLSetPluginIntegrationConfigMutation, + GQLSetPluginIntegrationConfigMutationVariables + >, +) { + const options = { ...defaultOptions, ...baseOptions }; + return Apollo.useMutation< + GQLSetPluginIntegrationConfigMutation, + GQLSetPluginIntegrationConfigMutationVariables + >(GQLSetPluginIntegrationConfigDocument, options); +} +export type GQLSetPluginIntegrationConfigMutationHookResult = ReturnType< + typeof useGQLSetPluginIntegrationConfigMutation +>; +export type GQLSetPluginIntegrationConfigMutationResult = + Apollo.MutationResult; +export type GQLSetPluginIntegrationConfigMutationOptions = + Apollo.BaseMutationOptions< + GQLSetPluginIntegrationConfigMutation, + GQLSetPluginIntegrationConfigMutationVariables + >; export const GQLIntegrationConfigDocument = gql` - query IntegrationConfig($name: Integration!) { + query IntegrationConfig($name: String!) { integrationConfig(name: $name) { ... on IntegrationConfigSuccessResult { config { name + title + docsUrl + requiresConfig + logoUrl + logoWithBackgroundUrl + modelCard { + modelName + version + releaseDate + sections { + id + title + subsections { + title + fields { + label + value + } + } + fields { + label + value + } + } + } + modelCardLearnMoreUrl apiCredential { ... on GoogleContentSafetyApiIntegrationApiCredential { apiKey @@ -27382,6 +27637,9 @@ export const GQLIntegrationConfigDocument = gql` label } } + ... on PluginIntegrationApiCredential { + credential + } } } } @@ -27504,6 +27762,68 @@ export type GQLMyIntegrationsQueryResult = Apollo.QueryResult< GQLMyIntegrationsQuery, GQLMyIntegrationsQueryVariables >; +export const GQLAvailableIntegrationsDocument = gql` + query AvailableIntegrations { + availableIntegrations { + name + title + docsUrl + requiresConfig + logoUrl + logoWithBackgroundUrl + } + } +`; + +/** + * __useGQLAvailableIntegrationsQuery__ + * + * To run a query within a React component, call `useGQLAvailableIntegrationsQuery` and pass it any options that fit your needs. + * When your component renders, `useGQLAvailableIntegrationsQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @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; + * + * @example + * const { data, loading, error } = useGQLAvailableIntegrationsQuery({ + * variables: { + * }, + * }); + */ +export function useGQLAvailableIntegrationsQuery( + baseOptions?: Apollo.QueryHookOptions< + GQLAvailableIntegrationsQuery, + GQLAvailableIntegrationsQueryVariables + >, +) { + const options = { ...defaultOptions, ...baseOptions }; + return Apollo.useQuery< + GQLAvailableIntegrationsQuery, + GQLAvailableIntegrationsQueryVariables + >(GQLAvailableIntegrationsDocument, options); +} +export function useGQLAvailableIntegrationsLazyQuery( + baseOptions?: Apollo.LazyQueryHookOptions< + GQLAvailableIntegrationsQuery, + GQLAvailableIntegrationsQueryVariables + >, +) { + const options = { ...defaultOptions, ...baseOptions }; + return Apollo.useLazyQuery< + GQLAvailableIntegrationsQuery, + GQLAvailableIntegrationsQueryVariables + >(GQLAvailableIntegrationsDocument, options); +} +export type GQLAvailableIntegrationsQueryHookResult = ReturnType< + typeof useGQLAvailableIntegrationsQuery +>; +export type GQLAvailableIntegrationsLazyQueryHookResult = ReturnType< + typeof useGQLAvailableIntegrationsLazyQuery +>; +export type GQLAvailableIntegrationsQueryResult = Apollo.QueryResult< + GQLAvailableIntegrationsQuery, + GQLAvailableIntegrationsQueryVariables +>; export const GQLInvestigationItemTypesDocument = gql` query InvestigationItemTypes { myOrg { @@ -37176,6 +37496,7 @@ export const namedOperations = { MatchingBankIds: 'MatchingBankIds', IntegrationConfig: 'IntegrationConfig', MyIntegrations: 'MyIntegrations', + AvailableIntegrations: 'AvailableIntegrations', InvestigationItemTypes: 'InvestigationItemTypes', GetOrgData: 'GetOrgData', GetItemsWithId: 'GetItemsWithId', @@ -37297,6 +37618,7 @@ export const namedOperations = { DeleteTextBank: 'DeleteTextBank', BulkActionExecution: 'BulkActionExecution', SetIntegrationConfig: 'SetIntegrationConfig', + SetPluginIntegrationConfig: 'SetPluginIntegrationConfig', DeleteItemType: 'DeleteItemType', CreateContentType: 'CreateContentType', UpdateContentType: 'UpdateContentType', diff --git a/client/src/models/signal.ts b/client/src/models/signal.ts index 3cb180a..685cd04 100644 --- a/client/src/models/signal.ts +++ b/client/src/models/signal.ts @@ -26,6 +26,9 @@ export type CoreSignal = Pick< | 'eligibleInputs' | 'subcategory' | 'integration' + | 'integrationTitle' + | 'integrationLogoUrl' + | 'integrationLogoWithBackgroundUrl' | 'pricingStructure' | 'docsUrl' | 'recommendedThresholds' @@ -34,7 +37,8 @@ export type CoreSignal = Pick< | 'allowedInAutomatedRules' >; -export function receivesRegexInput(type: GQLSignalType) { +/** Signal type is string to support plugin signal types (e.g. RANDOM_SIGNAL_SELECTION). */ +export function receivesRegexInput(type: string) { return ( type === GQLSignalType.TextMatchingContainsRegex || type === GQLSignalType.TextMatchingNotContainsRegex @@ -42,12 +46,11 @@ export function receivesRegexInput(type: GQLSignalType) { } /** - * This function returns the integration type for a given signal type - * @param type Signal type to find the integration for - * @returns a GQLIntegration enum value, or null in the case of signals that are - * not integrations + * Returns the integration type for a given signal type. + * @param type Signal type (built-in or plugin) + * @returns a GQLIntegration enum value, or null for non-integration signals or plugin signals */ -export function integrationForSignalType(type: GQLSignalType) { +export function integrationForSignalType(type: string) { switch (type) { case 'GOOGLE_CONTENT_SAFETY_API_IMAGE': return GQLIntegration.GoogleContentSafetyApi; @@ -80,7 +83,8 @@ export function integrationForSignalType(type: GQLSignalType) { case 'BENIGN_MODEL': return null; default: - assertUnreachable(type); + // Plugin signal types (e.g. RANDOM_SIGNAL_SELECTION) or unknown: no built-in integration + return null; } } diff --git a/client/src/utils/signalUtils.ts b/client/src/utils/signalUtils.ts index 5ab8ac8..6ce4d69 100644 --- a/client/src/utils/signalUtils.ts +++ b/client/src/utils/signalUtils.ts @@ -1,7 +1,7 @@ import { SignalSubcategory } from '@roostorg/types'; import transform from 'lodash/transform'; -import { GQLIntegration, GQLSignalSubcategory } from '../graphql/generated'; +import { GQLSignalSubcategory } from '../graphql/generated'; import { safePick } from './misc'; /** @@ -39,7 +39,7 @@ export function rebuildSubcategoryTreeFromGraphQLResponse( export function createSubcategoryIdToLabelMapping( signals: readonly { - integration?: GQLIntegration | null; + integration?: string | null; eligibleSubcategories: readonly { id: string; label: string }[]; }[], ) { diff --git a/client/src/webpages/dashboard/integrations/IntegrationCard.tsx b/client/src/webpages/dashboard/integrations/IntegrationCard.tsx index 902ff58..1c48660 100644 --- a/client/src/webpages/dashboard/integrations/IntegrationCard.tsx +++ b/client/src/webpages/dashboard/integrations/IntegrationCard.tsx @@ -1,16 +1,27 @@ import { ReactNode, useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; +import type { GQLIntegrationMetadata } from '../../../graphql/generated'; import CoopModal from '../components/CoopModal'; -import { IntegrationConfig } from './IntegrationsDashboard'; +import { INTEGRATION_LOGO_FALLBACKS } from './integrationLogos'; export default function IntegrationCard(props: { - integration: IntegrationConfig; + integration: GQLIntegrationMetadata; useExternalURL?: boolean; }) { const { integration, useExternalURL } = props; - const { name, title, logo, requiresInfo, url } = integration; + const { name, title, docsUrl } = integration; + // Integrations page uses only the plain logo (logoUrl from logoPath). Do not fall back to logoWithBackgroundUrl. + const rawLogo = + integration.logoUrl ?? + INTEGRATION_LOGO_FALLBACKS[name]?.logo ?? + ''; + // Resolve relative API paths to absolute URL so img loads correctly (e.g. /api/v1/integration-logos/ID). + const logo = + typeof rawLogo === 'string' && rawLogo.startsWith('/') + ? `${window.location.origin}${rawLogo}` + : rawLogo; const navigate = useNavigate(); const [modalVisible, setModalVisible] = useState(false); @@ -31,8 +42,10 @@ export default function IntegrationCard(props: { ]} >
-
- Logo +
+ {logo ? ( + + ) : null}
{title} doesn't require any @@ -56,12 +69,12 @@ export default function IntegrationCard(props: { }) => { if (Boolean(useExternalURL)) { return ( - + {children} ); } - if (!requiresInfo) { + if (!integration.requiresConfig) { return (
setModalVisible(true)} {...rest}> {children} @@ -81,8 +94,10 @@ export default function IntegrationCard(props: { return ( <> -
- Logo +
+ {logo ? ( + + ) : null}
{title} diff --git a/client/src/webpages/dashboard/integrations/IntegrationConfigApiCredentialsSection.tsx b/client/src/webpages/dashboard/integrations/IntegrationConfigApiCredentialsSection.tsx index 828578f..ace96bc 100644 --- a/client/src/webpages/dashboard/integrations/IntegrationConfigApiCredentialsSection.tsx +++ b/client/src/webpages/dashboard/integrations/IntegrationConfigApiCredentialsSection.tsx @@ -3,14 +3,13 @@ import { Plus, Trash2 } from 'lucide-react'; import { GQLGoogleContentSafetyApiIntegrationApiCredential, - GQLIntegration, GQLIntegrationApiCredential, GQLOpenAiIntegrationApiCredential, GQLZentropiIntegrationApiCredential, } from '../../../graphql/generated'; export default function IntegrationConfigApiCredentialsSection(props: { - name: GQLIntegration; + name: string; setApiCredential: (cred: GQLIntegrationApiCredential) => void; apiCredential: GQLIntegrationApiCredential; }) { @@ -142,6 +141,44 @@ export default function IntegrationConfigApiCredentialsSection(props: { ); }; + const PLUGIN_FIELD_LABELS: Record = { + truePercentage: 'True percentage (0–100)', + }; + + const renderPluginCredential = ( + pluginCredential: { __typename: 'PluginIntegrationApiCredential'; credential: Record }, + ) => { + const credential = pluginCredential.credential ?? {}; + const entries = Object.entries(credential).filter( + ([key]) => key !== 'name', + ); + const fieldsToShow = + entries.length > 0 + ? entries + : [['truePercentage', ''] as [string, unknown]]; + return ( +
+ {fieldsToShow.map(([key, value]) => ( +
+
+ {PLUGIN_FIELD_LABELS[key] ?? key} +
+ { + const next = { ...credential, [key]: event.target.value }; + setApiCredential({ + __typename: 'PluginIntegrationApiCredential', + credential: next as import('../../../graphql/generated').Scalars['JSONObject'], + }); + }} + /> +
+ ))} +
+ ); + }; + const projectKeysInput = () => { switch (apiCredential.__typename) { case 'GoogleContentSafetyApiIntegrationApiCredential': @@ -150,6 +187,8 @@ export default function IntegrationConfigApiCredentialsSection(props: { return renderOpenAiCredential(apiCredential); case 'ZentropiIntegrationApiCredential': return renderZentropiCredential(apiCredential); + case 'PluginIntegrationApiCredential': + return renderPluginCredential(apiCredential); default: throw new Error('Integration not implemented yet'); } diff --git a/client/src/webpages/dashboard/integrations/IntegrationConfigForm.tsx b/client/src/webpages/dashboard/integrations/IntegrationConfigForm.tsx index 1dd0137..fa4401f 100644 --- a/client/src/webpages/dashboard/integrations/IntegrationConfigForm.tsx +++ b/client/src/webpages/dashboard/integrations/IntegrationConfigForm.tsx @@ -1,5 +1,5 @@ import { gql } from '@apollo/client'; -import { useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; import { Helmet } from 'react-helmet-async'; import { useNavigate, useParams } from 'react-router-dom'; @@ -8,7 +8,6 @@ import CoopButton from '../components/CoopButton'; import CoopModal from '../components/CoopModal'; import { - GQLIntegration, GQLIntegrationApiCredential, GQLIntegrationConfigDocument, GQLUserPermission, @@ -16,6 +15,7 @@ import { useGQLIntegrationConfigQuery, useGQLPermissionGatedRouteLoggedInUserQuery, useGQLSetIntegrationConfigMutation, + useGQLSetPluginIntegrationConfigMutation, type GQLGoogleContentSafetyApiIntegrationApiCredential, type GQLOpenAiIntegrationApiCredential, type GQLZentropiIntegrationApiCredential, @@ -26,7 +26,8 @@ import { } from '../../../graphql/inputHelpers'; import { userHasPermissions } from '../../../routing/permissions'; import IntegrationConfigApiCredentialsSection from './IntegrationConfigApiCredentialsSection'; -import { INTEGRATION_CONFIGS } from './integrationConfigs'; +import { INTEGRATION_LOGO_FALLBACKS } from './integrationLogos'; +import ModelCardView from './ModelCardView'; gql` mutation SetIntegrationConfig($input: SetIntegrationConfigInput!) { @@ -48,11 +49,56 @@ gql` } } - query IntegrationConfig($name: Integration!) { + mutation SetPluginIntegrationConfig($input: SetPluginIntegrationConfigInput!) { + setPluginIntegrationConfig(input: $input) { + ... on SetIntegrationConfigSuccessResponse { + config { + name + } + } + ... on IntegrationConfigTooManyCredentialsError { + title + } + ... on IntegrationNoInputCredentialsError { + title + } + ... on IntegrationEmptyInputCredentialsError { + title + } + } + } + + query IntegrationConfig($name: String!) { integrationConfig(name: $name) { ... on IntegrationConfigSuccessResult { config { name + title + docsUrl + requiresConfig + logoUrl + logoWithBackgroundUrl + modelCard { + modelName + version + releaseDate + sections { + id + title + subsections { + title + fields { + label + value + } + } + fields { + label + value + } + } + } + modelCardLearnMoreUrl apiCredential { ... on GoogleContentSafetyApiIntegrationApiCredential { apiKey @@ -67,6 +113,9 @@ gql` label } } + ... on PluginIntegrationApiCredential { + credential + } } } } @@ -88,7 +137,7 @@ gql` * IntegrationConfigApiCredential), so the UI can display the proper empty inputs. */ export function getNewEmptyApiKey( - name: GQLIntegration, + name: string, ): GQLIntegrationApiCredential { switch (name) { case 'GOOGLE_CONTENT_SAFETY_API': { @@ -108,7 +157,10 @@ export function getNewEmptyApiKey( }; } default: { - throw new Error(`${name} integration not implemented.`); + return { + __typename: 'PluginIntegrationApiCredential', + credential: {}, + }; } } } @@ -119,12 +171,7 @@ export default function IntegrationConfigForm() { throw Error('Integration name not provided'); } // Cast back to upper case (see lowercase cast in IntegrationCard.tsx) - const integrationName = name.toUpperCase() as GQLIntegration; - const config = INTEGRATION_CONFIGS.find((i) => i.name === integrationName); - if (config == null) { - throw Error(`Integration with name ${name} not found`); - } - const formattedName = config.title; + const integrationName = name.toUpperCase(); const navigate = useNavigate(); const [modalVisible, setModalVisible] = useState(false); @@ -142,8 +189,17 @@ export default function IntegrationConfigForm() { }, onCompleted: () => showModal(), }); - const mutationError = setConfigMutationParams.error; - const mutationLoading = setConfigMutationParams.loading; + const [setPluginConfig, setPluginConfigMutationParams] = + useGQLSetPluginIntegrationConfigMutation({ + onError: () => { + showModal(); + }, + onCompleted: () => showModal(), + }); + const mutationError = + setConfigMutationParams.error ?? setPluginConfigMutationParams.error; + const mutationLoading = + setConfigMutationParams.loading || setPluginConfigMutationParams.loading; const { loading, @@ -177,9 +233,17 @@ export default function IntegrationConfigForm() { * If editing an existing config and the INTEGRATION_CONFIG_QUERY * has finished, reset the state values to whatever the query returned */ - useMemo(() => { + useEffect(() => { if (response?.config != null) { - setApiCredential(response.config.apiCredential); + const cred = response.config.apiCredential; + if (cred.__typename === 'PluginIntegrationApiCredential') { + setApiCredential({ + __typename: 'PluginIntegrationApiCredential', + credential: cred.credential ?? {}, + }); + } else { + setApiCredential(cred); + } } }, [response]); @@ -189,6 +253,19 @@ export default function IntegrationConfigForm() { if (loading || userQueryLoading) { return ; } + + const apiConfig = + response?.__typename === 'IntegrationConfigSuccessResult' + ? response.config + : undefined; + const formattedName = + apiConfig?.title ?? integrationName.replace(/_/g, ' '); + const logo = apiConfig + ? (apiConfig.logoUrl ?? + INTEGRATION_LOGO_FALLBACKS[apiConfig.name]?.logo ?? + '') + : ''; + const canEditConfig = userHasPermissions(permissions, [ GQLUserPermission.ManageOrg, ]); @@ -197,9 +274,18 @@ export default function IntegrationConfigForm() { GoogleContentSafetyApiIntegrationApiCredential: 'googleContentSafetyApi', OpenAiIntegrationApiCredential: 'openAi', ZentropiIntegrationApiCredential: 'zentropi', + PluginIntegrationApiCredential: 'pluginCredential', }); + const isPluginIntegration = ![ + 'GOOGLE_CONTENT_SAFETY_API', + 'OPEN_AI', + 'ZENTROPI', + ].includes(integrationName); const validationMessage = (() => { + if (isPluginIntegration) { + return undefined; + } if ( 'googleContentSafetyApi' in mappedApiCredential && !( @@ -237,22 +323,36 @@ export default function IntegrationConfigForm() { - setConfig({ - variables: { - input: { - apiCredential: stripTypename(mappedApiCredential), - }, + onClick={async () => { + const refetchQueries = [ + namedOperations.Query.MyIntegrations, + { + query: GQLIntegrationConfigDocument, + variables: { name: integrationName }, }, - refetchQueries: [ - namedOperations.Query.MyIntegrations, - { - query: GQLIntegrationConfigDocument, - variables: { name: integrationName }, + ]; + if (isPluginIntegration) { + const cred = + apiCredential.__typename === 'PluginIntegrationApiCredential' + ? apiCredential.credential ?? {} + : {}; + await setPluginConfig({ + variables: { + input: { integrationId: integrationName, credential: cred }, }, - ], - }) - } + refetchQueries, + }); + } else { + await setConfig({ + variables: { + input: { + apiCredential: stripTypename(mappedApiCredential), + }, + }, + refetchQueries, + }); + } + }} disabled={!canEditConfig || validationMessage != null} disabledTooltipTitle={validationMessage} /> @@ -296,11 +396,11 @@ export default function IntegrationConfigForm() { ); const headerSubtitle = ( - integration: GQLIntegration, + integrationName: string, formattedName: string, ): React.ReactNode | string | undefined => { - switch (integration) { - case GQLIntegration.GoogleContentSafetyApi: + switch (integrationName) { + case 'GOOGLE_CONTENT_SAFETY_API': return ( <> The Content Safety API is an AI classifier which issues a Child @@ -322,32 +422,83 @@ export default function IntegrationConfigForm() { back in touch shortly to take the application forward if you qualify. ); - case GQLIntegration.OpenAi: + case 'OPEN_AI': return `The ${formattedName} integration requires one API Key.`; default: return undefined; } }; + const apiModelCard = response?.config?.modelCard; + const apiModelCardLearnMoreUrl = response?.config?.modelCardLearnMoreUrl; + const hasModelCard = apiModelCard != null; + return (
{formattedName} Integration
-
{`${formattedName} Integration`}
-
- {headerSubtitle(integrationName, formattedName)} +
+
+ +
+
{`${formattedName} Integration`}
+ {!hasModelCard && ( +
+ {headerSubtitle(integrationName, formattedName)} +
+ )}
- - setApiCredential(cred) - } - /> - {saveButton} + + {hasModelCard && apiModelCard ? ( +
+
+ +
+
+ {apiModelCardLearnMoreUrl != null && ( + + ⓘ + Learn more about how to read model cards + + )} +
Credentials
+
+ Configure your credentials below. +
+ + setApiCredential(cred) + } + /> + {saveButton} +
+
+ ) : ( + <> + + setApiCredential(cred) + } + /> + {saveButton} + + )} {modal}
); diff --git a/client/src/webpages/dashboard/integrations/IntegrationsDashboard.tsx b/client/src/webpages/dashboard/integrations/IntegrationsDashboard.tsx index 39910ab..38d318f 100644 --- a/client/src/webpages/dashboard/integrations/IntegrationsDashboard.tsx +++ b/client/src/webpages/dashboard/integrations/IntegrationsDashboard.tsx @@ -5,20 +5,10 @@ import FullScreenLoading from '../../../components/common/FullScreenLoading'; import DashboardHeader from '../components/DashboardHeader'; import { - GQLIntegration, + useGQLAvailableIntegrationsQuery, useGQLMyIntegrationsQuery, } from '../../../graphql/generated'; import IntegrationCard from './IntegrationCard'; -import { INTEGRATION_CONFIGS } from './integrationConfigs'; - -export type IntegrationConfig = { - name: GQLIntegration; - title: string; - logo: string; - logoWithBackground: string; - url: string; - requiresInfo: boolean; -}; export default function IntegrationsDashboard() { gql` @@ -31,7 +21,27 @@ export default function IntegrationsDashboard() { } `; - const { loading, error, data } = useGQLMyIntegrationsQuery(); + gql` + query AvailableIntegrations { + availableIntegrations { + name + title + docsUrl + requiresConfig + logoUrl + logoWithBackgroundUrl + } + } + `; + + const { loading: loadingCatalog, data: catalogData } = + useGQLAvailableIntegrationsQuery({ + fetchPolicy: 'network-only', + }); + const { loading: loadingMy, error, data: myData } = + useGQLMyIntegrationsQuery(); + + const loading = loadingCatalog || loadingMy; if (loading) { return ; @@ -41,15 +51,15 @@ export default function IntegrationsDashboard() { throw error; } - const integrationNames = - data?.myOrg?.integrationConfigs?.map((config) => config.name) ?? []; + const allIntegrations = catalogData?.availableIntegrations ?? []; + const myIntegrationNames = + myData?.myOrg?.integrationConfigs?.map((config) => config.name) ?? []; - const myIntegrations = INTEGRATION_CONFIGS.filter((it) => - integrationNames.includes(it.name), + const myIntegrations = allIntegrations.filter((it) => + myIntegrationNames.includes(it.name), ); - - const otherIntegrations = INTEGRATION_CONFIGS.filter( - (it) => !myIntegrations.includes(it), + const otherIntegrations = allIntegrations.filter( + (it) => !myIntegrationNames.includes(it.name), ).sort((a, b) => a.name.localeCompare(b.name)); return ( diff --git a/client/src/webpages/dashboard/integrations/ModelCardView.tsx b/client/src/webpages/dashboard/integrations/ModelCardView.tsx new file mode 100644 index 0000000..4c6ed43 --- /dev/null +++ b/client/src/webpages/dashboard/integrations/ModelCardView.tsx @@ -0,0 +1,127 @@ +import { useState } from 'react'; +import { ChevronDown, ChevronRight } from 'lucide-react'; + +import type { + GQLModelCard, + GQLModelCardField, + GQLModelCardSection, + GQLModelCardSubsection, +} from '../../../graphql/generated'; + +type ModelCardViewProps = { card: GQLModelCard }; + +/** + * Renders a single label-value row. Linkifies URLs in value. + */ +function ModelCardFieldRow({ field }: { field: GQLModelCardField }) { + const isUrl = + field.value.startsWith('http://') || field.value.startsWith('https://'); + return ( +
+ {field.label} + {isUrl ? ( + + {field.value} + + ) : ( + {field.value} + )} +
+ ); +} + +function SubsectionBlock({ + subsection, +}: { + subsection: GQLModelCardSubsection; +}) { + return ( +
+
{subsection.title}
+
+ {subsection.fields.map((field, i) => ( + + ))} +
+
+ ); +} + +function ModelCardSectionBlock({ + section, + defaultOpen = true, +}: { + section: GQLModelCardSection; + defaultOpen?: boolean; +}) { + const [open, setOpen] = useState(defaultOpen); + const hasSubsections = section.subsections && section.subsections.length > 0; + const hasFields = section.fields && section.fields.length > 0; + const hasContent = hasSubsections ?? hasFields; + + return ( +
+ + {open && hasContent && ( +
+ {hasSubsections && + section.subsections?.map((sub) => ( + + ))} + {hasFields && !hasSubsections && ( +
+ {section.fields?.map((field, i) => ( + + ))} +
+ )} +
+ )} +
+ ); +} + +export default function ModelCardView({ card }: ModelCardViewProps) { + const sections = card.sections ?? []; + return ( +
+
+ + {card.modelName} + + {card.version} + {card.releaseDate != null && ( + {card.releaseDate} + )} +
+
+ {sections.map((section) => ( + + ))} +
+
+ ); +} diff --git a/client/src/webpages/dashboard/integrations/integrationConfigs.ts b/client/src/webpages/dashboard/integrations/integrationConfigs.ts index 159fc3d..f74993b 100644 --- a/client/src/webpages/dashboard/integrations/integrationConfigs.ts +++ b/client/src/webpages/dashboard/integrations/integrationConfigs.ts @@ -1,14 +1,27 @@ -import { GQLIntegration } from '../../../graphql/generated'; +/** + * Client-side integration list for contexts that do not yet use the API + * (e.g. rule form signal modals). Prefer backend-driven data (availableIntegrations, + * integrationConfig) for the integrations dashboard and detail page. + */ +import type { GQLIntegration } from '../../../graphql/generated'; import GoogleLogo from '../../../images/GoogleLogo.png'; import GoogleLogoWithBackground from '../../../images/GoogleLogoWithBackground.png'; import OpenAILogo from '../../../images/OpenAILogo.png'; import OpenAILogoWithBackground from '../../../images/OpenAILogoWithBackground.png'; import ZentropiLogo from '../../../images/ZentropiLogo.png'; -import { IntegrationConfig } from './IntegrationsDashboard'; + +export type IntegrationConfig = { + name: GQLIntegration; + title: string; + logo: string; + logoWithBackground: string; + url: string; + requiresInfo: boolean; +}; export const INTEGRATION_CONFIGS: IntegrationConfig[] = [ { - name: GQLIntegration.GoogleContentSafetyApi, + name: 'GOOGLE_CONTENT_SAFETY_API' as GQLIntegration, title: 'Google Content Safety API', logo: GoogleLogo, logoWithBackground: GoogleLogoWithBackground, @@ -16,7 +29,7 @@ export const INTEGRATION_CONFIGS: IntegrationConfig[] = [ requiresInfo: true, }, { - name: GQLIntegration.OpenAi, + name: 'OPEN_AI' as GQLIntegration, title: 'OpenAI', logo: OpenAILogo, logoWithBackground: OpenAILogoWithBackground, @@ -24,7 +37,7 @@ export const INTEGRATION_CONFIGS: IntegrationConfig[] = [ requiresInfo: true, }, { - name: GQLIntegration.Zentropi, + name: 'ZENTROPI' as GQLIntegration, title: 'Zentropi', logo: ZentropiLogo, logoWithBackground: ZentropiLogo, diff --git a/client/src/webpages/dashboard/integrations/integrationLogos.ts b/client/src/webpages/dashboard/integrations/integrationLogos.ts new file mode 100644 index 0000000..df5acd2 --- /dev/null +++ b/client/src/webpages/dashboard/integrations/integrationLogos.ts @@ -0,0 +1,27 @@ +/** + * Fallback logo assets when the backend does not provide logoUrl. + * Integrations are backend-driven; logos can come from API (logoUrl) or this map. + * Keys are integration names (built-in enum or plugin id string). + */ +import GoogleLogo from '../../../images/GoogleLogo.png'; +import GoogleLogoWithBackground from '../../../images/GoogleLogoWithBackground.png'; +import OpenAILogo from '../../../images/OpenAILogo.png'; +import OpenAILogoWithBackground from '../../../images/OpenAILogoWithBackground.png'; +import ZentropiLogo from '../../../images/ZentropiLogo.png'; + +export const INTEGRATION_LOGO_FALLBACKS: Partial< + Record +> = { + GOOGLE_CONTENT_SAFETY_API: { + logo: GoogleLogo, + logoWithBackground: GoogleLogoWithBackground, + }, + OPEN_AI: { + logo: OpenAILogo, + logoWithBackground: OpenAILogoWithBackground, + }, + ZENTROPI: { + logo: ZentropiLogo, + logoWithBackground: ZentropiLogo, + }, +}; diff --git a/client/src/webpages/dashboard/rules/info/insights/RuleInsightsSamplesTable.tsx b/client/src/webpages/dashboard/rules/info/insights/RuleInsightsSamplesTable.tsx index e25b652..ddfbf4e 100644 --- a/client/src/webpages/dashboard/rules/info/insights/RuleInsightsSamplesTable.tsx +++ b/client/src/webpages/dashboard/rules/info/insights/RuleInsightsSamplesTable.tsx @@ -29,7 +29,6 @@ import Table from '../../../components/table/Table'; import { GQLField, GQLFieldType, - GQLIntegration, GQLRuleStatus, useGQLRuleInsightsCurrentVersionSamplesQuery, useGQLRuleInsightsPriorVersionSamplesLazyQuery, @@ -62,7 +61,7 @@ export enum RuleEnvironment { export type SignalWithResult = { subcategory?: string | null; signalName: string; - integration?: GQLIntegration | null | undefined; + integration?: string | null | undefined; score?: string; }; diff --git a/client/src/webpages/dashboard/rules/info/insights/sample_details/RuleInsightsSampleDetailMatchingValues.tsx b/client/src/webpages/dashboard/rules/info/insights/sample_details/RuleInsightsSampleDetailMatchingValues.tsx index d352131..cf4910a 100644 --- a/client/src/webpages/dashboard/rules/info/insights/sample_details/RuleInsightsSampleDetailMatchingValues.tsx +++ b/client/src/webpages/dashboard/rules/info/insights/sample_details/RuleInsightsSampleDetailMatchingValues.tsx @@ -100,8 +100,9 @@ export default function RuleInsightsSampleDetailMatchingValues(props: { matchingValues.strings!, result?.outcome, result?.matchedValue ?? undefined, - (condition.signal?.type && receivesRegexInput(condition.signal.type)) ?? - false, + Boolean( + condition.signal?.type && receivesRegexInput(condition.signal.type), + ), ); case MatchingValueType.LOCATION: return renderMatchingValuesStringsInput( diff --git a/client/src/webpages/dashboard/rules/rule_form/RuleForm.tsx b/client/src/webpages/dashboard/rules/rule_form/RuleForm.tsx index 1a0ad6c..51dedfe 100644 --- a/client/src/webpages/dashboard/rules/rule_form/RuleForm.tsx +++ b/client/src/webpages/dashboard/rules/rule_form/RuleForm.tsx @@ -201,6 +201,9 @@ export const SIGNALS_FRAGMENT = gql` type name integration + integrationTitle + integrationLogoUrl + integrationLogoWithBackgroundUrl docsUrl recommendedThresholds { highPrecisionThreshold diff --git a/client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModal.tsx b/client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModal.tsx index 6d2c08b..cf860dd 100644 --- a/client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModal.tsx +++ b/client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModal.tsx @@ -14,10 +14,7 @@ export default function RuleFormSignalModal(props: { allSignals: CoreSignal[]; onSelectSignal: (signal: CoreSignal, subcategoryOption?: string) => void; onClose: () => void; - // If the user has already selected a signal, the modal needs to know - // which signal it is. selectedSignal?: CoreSignal; - // Whether this is the automated rule form ( proactive/autoenforcements ) isAutomatedRule?: boolean; }) { const { visible, allSignals, onSelectSignal, onClose, selectedSignal, isAutomatedRule } = diff --git a/client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalMenuItem.tsx b/client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalMenuItem.tsx index 523b946..714429e 100644 --- a/client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalMenuItem.tsx +++ b/client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalMenuItem.tsx @@ -10,15 +10,26 @@ import LogoWhiteWithBackground from '../../../../../images/LogoWhiteWithBackgrou import { CoreSignal } from '../../../../../models/signal'; import { INTEGRATION_CONFIGS } from '../../../integrations/integrationConfigs'; +/** Vendor/company name for display. Uses signal.integrationTitle (from API) when set, else static config, else formatted id. */ export function vendorName(signal: CoreSignal) { if (signal.type === GQLSignalType.Custom) { return 'Custom'; - } else if (!signal.integration) { + } + if (!signal.integration) { return 'Coop'; - } else { - return INTEGRATION_CONFIGS.find((it) => it.name === signal.integration)! - .title; } + if (signal.integrationTitle) { + return signal.integrationTitle; + } + const staticConfig = INTEGRATION_CONFIGS.find( + (it) => it.name === signal.integration, + ); + if (staticConfig) { + return staticConfig.title; + } + return typeof signal.integration === 'string' + ? signal.integration.replace(/_/g, ' ') + : 'Plugin'; } export function signalDisplayName(signal: CoreSignal, hideVendor = true) { diff --git a/client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalSignalDetailView.tsx b/client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalSignalDetailView.tsx index 28cdf46..d6796bb 100644 --- a/client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalSignalDetailView.tsx +++ b/client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalSignalDetailView.tsx @@ -21,9 +21,27 @@ export default function RuleFormSignalModalSignalDetailView(props: { ) => void; }) { const { signal, subcategories, onSelectSignal } = props; - const integration = INTEGRATION_CONFIGS.find( + const staticConfig = INTEGRATION_CONFIGS.find( (it) => it.name === signal.integration, ); + const integrationTitle = + signal.integrationTitle ?? + staticConfig?.title ?? + (typeof signal.integration === 'string' + ? signal.integration + .replace(/_/g, ' ') + .toLowerCase() + .replace(/^([a-z])|\s+([a-z])/g, (m) => m.toUpperCase()) + : 'Coop'); + // Signals use the logo-with-background variant. + const rawLogoSrc = + signal.integrationLogoWithBackgroundUrl ?? + staticConfig?.logoWithBackground ?? + LogoWhiteWithBackground; + const logoSrc = + typeof rawLogoSrc === 'string' && rawLogoSrc.startsWith('/') + ? `${window.location.origin}${rawLogoSrc}` + : rawLogoSrc; const infoSectionData = [ { @@ -33,9 +51,9 @@ export default function RuleFormSignalModalSignalDetailView(props: { logo{' '} - {integration?.title ?? 'Coop'} + {integrationTitle}
), }, diff --git a/client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalSignalGallery.tsx b/client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalSignalGallery.tsx index dc42393..3303296 100644 --- a/client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalSignalGallery.tsx +++ b/client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalSignalGallery.tsx @@ -23,12 +23,14 @@ export default function RuleFormSignalModalSignalGallery(props: { const filteredSignals = useMemo( () => allSignals - // First filter out disabled signals + // Show built-in Coop signals (no integration), known integrations (in INTEGRATION_CONFIGS), or plugin integrations (any other string) .filter((signal) => + signal.integration === null || INTEGRATION_CONFIGS.some( - (config) => - signal.integration === config.name || signal.integration === null, - ), + (config) => signal.integration === config.name, + ) || + (typeof signal.integration === 'string' && + signal.integration.length > 0), ) // Then filter out the text similarity score signals .filter((it) => it.type !== 'TEXT_SIMILARITY_SCORE') @@ -40,8 +42,7 @@ export default function RuleFormSignalModalSignalGallery(props: { ) // Filter out 3rd party signals for demo orgs .filter((signal) => !(isDemoOrg && signal.integration)), - // eslint-disable-next-line react-hooks/exhaustive-deps - [isDemoOrg, searchTerm], + [allSignals, isDemoOrg, searchTerm], ); return ( @@ -85,22 +86,32 @@ export default function RuleFormSignalModalSignalGallery(props: { `${b.signal.integration}_${b.signal.name}`, ), ) - .map(({ signal, effectiveDisabledInfo }) => ( -
+ .map(({ signal, effectiveDisabledInfo }) => { + const staticConfig = INTEGRATION_CONFIGS.find( + (it) => it.name === signal.integration, + ); + // Signals use the logo-with-background variant. + const rawPath = + signal.integrationLogoWithBackgroundUrl ?? + staticConfig?.logoWithBackground ?? + undefined; + const imagePath = + typeof rawPath === 'string' && rawPath.startsWith('/') + ? `${window.location.origin}${rawPath}` + : rawPath; + return ( +
it.name === signal.integration, - )?.logoWithBackground - } + imagePath={imagePath} onClick={() => onSelectSignal(signal)} infoButtonTapped={() => onSignalInfoSelected(signal)} disabledInfo={effectiveDisabledInfo} /> -
- ))} +
+ ); + })}
) : ( diff --git a/client/tsconfig.json b/client/tsconfig.json index da8c1cb..6129e1e 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -7,7 +7,11 @@ "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "allowSyntheticDefaultImports": true, "noFallthroughCasesInSwitch": true, @@ -17,9 +21,12 @@ "noEmit": true, "downlevelIteration": true, "paths": { - "@/*": ["./src/*"], - "@roostorg/types": ["../types"] + "@/*": [ + "./src/*" + ] } }, - "include": ["src"] + "include": [ + "src" + ] } diff --git a/integrations.config.example.json b/integrations.config.example.json new file mode 100644 index 0000000..ed383e7 --- /dev/null +++ b/integrations.config.example.json @@ -0,0 +1,24 @@ +{ + "integrations": [ + { + "package": "@roostorg/coop-integration-example", + "enabled": true + }, + { + "package": "../coop-integration-example", + "enabled": false + }, + { + "package": "@acme/coop-integration-acme", + "enabled": false + }, + { + "package": "./local-integrations/my-custom-integration", + "enabled": false, + "config": { + "endpoint": "https://api.example.com", + "timeoutMs": 5000 + } + } + ] +} \ No newline at end of file diff --git a/server/bin/www.ts b/server/bin/www.ts index 4d94628..61889f3 100755 --- a/server/bin/www.ts +++ b/server/bin/www.ts @@ -5,6 +5,10 @@ import _ from 'lodash'; import getBottle from '../iocContainer/index.js'; import makeServer from '../server.js'; +import { + getIntegrationRegistry, + getIntegrationsConfigPath, +} from '../services/integrationRegistry/index.js'; import { logErrorJson, logJson } from '../utils/logging.js'; import { sleep } from '../utils/misc.js'; @@ -12,6 +16,24 @@ const { app, shutdown } = await getBottle().then(async (bottle) => makeServer(bottle.container), ); +// Eager-load integration registry so config/plugins are read at startup (fail fast, and so logo URLs are set). +try { + const registry = getIntegrationRegistry(); + const configPath = getIntegrationsConfigPath(); + const ids = registry.getConfigurableIds(); + // eslint-disable-next-line no-restricted-syntax + logJson( + `Integrations: config=${configPath}, loaded=${ids.length} (${ids.join(', ')})`, + ); +} catch (err) { + // eslint-disable-next-line no-restricted-syntax + logErrorJson({ + message: 'Failed to load integrations registry', + error: err instanceof Error ? err : new Error(String(err)), + }); + process.exit(1); +} + const port = parsePort(process.env.PORT) ?? 8080; app.set('port', port); diff --git a/server/condition_evaluator/conditionSet.ts b/server/condition_evaluator/conditionSet.ts index aee243d..68a0e53 100644 --- a/server/condition_evaluator/conditionSet.ts +++ b/server/condition_evaluator/conditionSet.ts @@ -261,11 +261,13 @@ export function tryGetOutcomeFromPartialOutcomes( export function getAllAggregationsInConditionSet( conditionSet: ReadonlyDeep, ): ReadonlyDeep[] { - return conditionSet.conditions.flatMap((condition) => - isConditionSet(condition) - ? getAllAggregationsInConditionSet(condition) - : condition.signal?.type === 'AGGREGATION' - ? [condition.signal.args.aggregationClause] - : [], - ); + return conditionSet.conditions.flatMap((condition) => { + if (isConditionSet(condition)) { + return getAllAggregationsInConditionSet(condition); + } + const sig = condition.signal; + const args = + sig?.type === 'AGGREGATION' ? sig.args : undefined; + return args != null ? [args.aggregationClause] : []; + }); } diff --git a/server/condition_evaluator/leafCondition.ts b/server/condition_evaluator/leafCondition.ts index eb3cf44..c8a6bce 100644 --- a/server/condition_evaluator/leafCondition.ts +++ b/server/condition_evaluator/leafCondition.ts @@ -560,11 +560,14 @@ async function getSignalRuntimeArgs( conditionSignalInfo: ReadonlyDeep, ) { if (conditionSignalInfo.type === 'AGGREGATION') { - return evaluateAggregationRuntimeArgsForItem( - evaluationContext, - itemSubmission, - conditionSignalInfo.args.aggregationClause, - ); + const args = conditionSignalInfo.args; + if (args != null) { + return evaluateAggregationRuntimeArgsForItem( + evaluationContext, + itemSubmission, + args.aggregationClause, + ); + } } return undefined; } diff --git a/server/graphql/datasources/IntegrationApi.ts b/server/graphql/datasources/IntegrationApi.ts index fbe6ba2..9e10b3a 100644 --- a/server/graphql/datasources/IntegrationApi.ts +++ b/server/graphql/datasources/IntegrationApi.ts @@ -1,29 +1,63 @@ import { DataSource } from 'apollo-datasource'; import { inject, type Dependencies } from '../../iocContainer/index.js'; -import { - configurableIntegrations, - type ConfigurableIntegration, - type CredentialTypes, -} from '../../services/signalAuthService/index.js'; -import { filterNullOrUndefined } from '../../utils/collections.js'; +import '../../services/signalAuthService/index.js'; +import { Integration } from '../../services/signalsService/index.js'; import { CoopError, ErrorType, type ErrorInstanceData, } from '../../utils/errors.js'; +import { getIntegrationRegistry } from '../../services/integrationRegistry/index.js'; +import type { + IntegrationManifestEntry, + ModelCard, +} from './integrationManifests.js'; import { type GQLSetIntegrationConfigInput } from '../generated.js'; -export type TIntegrationConfig = { - [K in keyof CredentialTypes]: { - name: K; - apiCredential: { - name: K; - } & CredentialTypes[K]; - }; -}[ConfigurableIntegration]; +export type TIntegrationConfigWithMetadata = Readonly<{ + name: string; + apiCredential: Readonly>; + modelCard: ModelCard; + modelCardLearnMoreUrl?: string; + title: string; + docsUrl: string; + requiresConfig: boolean; + logoUrl?: string; + logoWithBackgroundUrl?: string; +}>; + +function defaultCredentialForIntegrationId( + integrationId: string, +): Record { + switch (integrationId) { + case Integration.GOOGLE_CONTENT_SAFETY_API: + case Integration.OPEN_AI: + return { apiKey: '' }; + case Integration.ZENTROPI: + return { apiKey: '', labelerVersions: [] }; + default: + return {}; + } +} -export type TIntegrationCredential = TIntegrationConfig['apiCredential']; +function mergeManifest( + integrationId: string, + apiCredential: Record, + manifest: IntegrationManifestEntry, +): TIntegrationConfigWithMetadata { + return { + name: integrationId, + apiCredential: { ...apiCredential, name: integrationId }, + modelCard: manifest.modelCard, + modelCardLearnMoreUrl: manifest.modelCardLearnMoreUrl, + title: manifest.title, + docsUrl: manifest.docsUrl, + requiresConfig: manifest.requiresConfig, + logoUrl: manifest.logoUrl, + logoWithBackgroundUrl: manifest.logoWithBackgroundUrl, + }; +} /** * TODO: this whole class should probably be merged into the signal auth service. @@ -38,27 +72,25 @@ class IntegrationAPI extends DataSource { async setConfig( params: GQLSetIntegrationConfigInput, orgId: string, - ): Promise { + ): Promise { const { apiCredential } = params; if (apiCredential.googleContentSafetyApi) { - return this.__private__setConfig( + return this.setConfigByIntegrationId( 'GOOGLE_CONTENT_SAFETY_API', { apiKey: apiCredential.googleContentSafetyApi.apiKey }, orgId, ); } - if (apiCredential.openAi) { - return this.__private__setConfig( + return this.setConfigByIntegrationId( 'OPEN_AI', { apiKey: apiCredential.openAi.apiKey }, orgId, ); } - if (apiCredential.zentropi) { - return this.__private__setConfig( + return this.setConfigByIntegrationId( 'ZENTROPI', { apiKey: apiCredential.zentropi.apiKey, @@ -71,50 +103,70 @@ class IntegrationAPI extends DataSource { throw new Error('No credentials provided'); } - async getConfig( + async setConfigByIntegrationId( + integrationId: string, + credential: Record, orgId: string, - integration: ConfigurableIntegration, - ): Promise { - const credential = await this.signalAuthService.get(integration, orgId); - if (credential == null) { - return undefined; + ): Promise { + const registry = getIntegrationRegistry(); + const manifest = registry.getManifest(integrationId); + if (manifest == null) { + throw new Error(`Unknown integration: ${integrationId}`); } - - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - return { - name: integration, - apiCredential: { name: integration, ...credential }, - } as TIntegrationConfig; + const newCredential = await this.signalAuthService.setByIntegrationId( + integrationId, + orgId, + credential, + ); + return mergeManifest(integrationId, newCredential, manifest); } - async getAllIntegrationConfigs(orgId: string): Promise { - const allConfigs = await Promise.all( - configurableIntegrations.map(async (integration) => - this.getConfig(orgId, integration), - ), + async getConfig( + orgId: string, + integrationId: string, + ): Promise { + const registry = getIntegrationRegistry(); + const manifest = registry.getManifest(integrationId); + if (manifest == null) return undefined; + const credential = await this.signalAuthService.getByIntegrationId( + integrationId, + orgId, ); - return filterNullOrUndefined(allConfigs); + if (credential == null) return undefined; + return mergeManifest(integrationId, credential, manifest); } - async __private__setConfig( - integration: T, - credential: CredentialTypes[T], + async getConfigWithMetadata( orgId: string, - ): Promise { - // When we're updating an existing credentials object, we have an id available, representing - // the credentials object we need to update. When no id is passed in, then we're creating - // a new credentials object. - const newCredential = await this.signalAuthService.set( - integration, + integrationId: string, + ): Promise { + const registry = getIntegrationRegistry(); + const manifest = registry.getManifest(integrationId); + if (manifest == null) { + throw new Error(`Unknown integration: ${integrationId}`); + } + const credential = await this.signalAuthService.getByIntegrationId( + integrationId, orgId, - credential, ); + const apiCredential = + credential ?? defaultCredentialForIntegrationId(integrationId); + return mergeManifest(integrationId, apiCredential, manifest); + } + + getAvailableIntegrations() { + return getIntegrationRegistry().getAvailableIntegrations(); + } - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - return { - name: integration, - apiCredential: { name: integration, ...newCredential }, - } as TIntegrationConfig; + async getAllIntegrationConfigs( + orgId: string, + ): Promise { + const ids = getIntegrationRegistry().getConfigurableIds(); + return Promise.all( + ids.map(async (integrationId) => + this.getConfigWithMetadata(orgId, integrationId), + ), + ); } } diff --git a/server/graphql/datasources/RuleApi.ts b/server/graphql/datasources/RuleApi.ts index 719884e..f73d530 100644 --- a/server/graphql/datasources/RuleApi.ts +++ b/server/graphql/datasources/RuleApi.ts @@ -36,7 +36,6 @@ import { import { isSignalId, signalIsExternal, - type ExternalSignalType, type SignalId, } from '../../services/signalsService/index.js'; import { type ConditionSetWithResultAsLogged } from '../../services/analyticsLoggers/index.js'; @@ -881,19 +880,16 @@ class RuleAPI extends DataSource { processCondition(subCondition); } } else if ('signal' in condition && condition.signal) { - // It's a leaf condition with a signal + // It's a leaf condition with a signal (type is String to support plugin signals) const { type, id } = condition.signal; - // GQLSignalType values map to ExternalSignalType (conditions can only - // contain user-visible external signals). Cast is safe since this comes - // from validated GraphQL input. let signalId: SignalId; if (type === 'CUSTOM') { // CUSTOM signals require an id field. The id comes from validated GraphQL // input where it's a required Scalars['ID'], so we can safely cast it. signalId = { type: 'CUSTOM' as const, id: id as NonEmptyString }; } else { - // Built-in signals only need the type - signalId = { type: type as Exclude }; + // Built-in and plugin signals: type is the signal type string + signalId = { type }; } signalIds.push(signalId); } diff --git a/server/graphql/datasources/integrationManifests.ts b/server/graphql/datasources/integrationManifests.ts new file mode 100644 index 0000000..51cc28e --- /dev/null +++ b/server/graphql/datasources/integrationManifests.ts @@ -0,0 +1,13 @@ +/** + * Re-export built-in manifests and types from the integration registry + * so GraphQL datasources can use them without the registry depending on graphql. + */ +export { + BUILT_IN_MANIFESTS, + type AvailableIntegration, + type IntegrationManifestEntry, + type ModelCard, + type ModelCardField, + type ModelCardSection, + type ModelCardSubsection, +} from '../../services/integrationRegistry/index.js'; diff --git a/server/graphql/generated.ts b/server/graphql/generated.ts index 3bced1e..a3e49ab 100644 --- a/server/graphql/generated.ts +++ b/server/graphql/generated.ts @@ -470,7 +470,7 @@ export type GQLConditionInputSignalInput = { readonly id: Scalars['ID']; readonly name?: InputMaybe; readonly subcategory?: InputMaybe; - readonly type: GQLSignalType; + readonly type: Scalars['String']; }; export type GQLConditionMatchingValuesInput = { @@ -1328,6 +1328,7 @@ export type GQLIntegration = export type GQLIntegrationApiCredential = | GQLGoogleContentSafetyApiIntegrationApiCredential | GQLOpenAiIntegrationApiCredential + | GQLPluginIntegrationApiCredential | GQLZentropiIntegrationApiCredential; export type GQLIntegrationApiCredentialInput = { @@ -1339,7 +1340,14 @@ export type GQLIntegrationApiCredentialInput = { export type GQLIntegrationConfig = { readonly __typename?: 'IntegrationConfig'; readonly apiCredential: GQLIntegrationApiCredential; - readonly name: GQLIntegration; + readonly docsUrl: Scalars['String']; + readonly logoUrl?: Maybe; + readonly logoWithBackgroundUrl?: Maybe; + readonly modelCard: GQLModelCard; + readonly modelCardLearnMoreUrl?: Maybe; + readonly name: Scalars['String']; + readonly requiresConfig: Scalars['Boolean']; + readonly title: Scalars['String']; }; export type GQLIntegrationConfigQueryResponse = @@ -1381,6 +1389,16 @@ export type GQLIntegrationEmptyInputCredentialsError = GQLError & { readonly type: ReadonlyArray; }; +export type GQLIntegrationMetadata = { + readonly __typename?: 'IntegrationMetadata'; + readonly docsUrl: Scalars['String']; + readonly logoUrl?: Maybe; + readonly logoWithBackgroundUrl?: Maybe; + readonly name: Scalars['String']; + readonly requiresConfig: Scalars['Boolean']; + readonly title: Scalars['String']; +}; + export type GQLIntegrationNoInputCredentialsError = GQLError & { readonly __typename?: 'IntegrationNoInputCredentialsError'; readonly detail?: Maybe; @@ -2163,6 +2181,34 @@ export const GQLMetricsTimeDivisionOptions = { export type GQLMetricsTimeDivisionOptions = (typeof GQLMetricsTimeDivisionOptions)[keyof typeof GQLMetricsTimeDivisionOptions]; +export type GQLModelCard = { + readonly __typename?: 'ModelCard'; + readonly modelName: Scalars['String']; + readonly releaseDate?: Maybe; + readonly sections?: Maybe>; + readonly version: Scalars['String']; +}; + +export type GQLModelCardField = { + readonly __typename?: 'ModelCardField'; + readonly label: Scalars['String']; + readonly value: Scalars['String']; +}; + +export type GQLModelCardSection = { + readonly __typename?: 'ModelCardSection'; + readonly fields?: Maybe>; + readonly id: Scalars['String']; + readonly subsections?: Maybe>; + readonly title: Scalars['String']; +}; + +export type GQLModelCardSubsection = { + readonly __typename?: 'ModelCardSubsection'; + readonly fields: ReadonlyArray; + readonly title: Scalars['String']; +}; + export type GQLModeratorSafetySettingsInput = { readonly moderatorSafetyBlurLevel: Scalars['Int']; readonly moderatorSafetyGrayscale: Scalars['Boolean']; @@ -2336,6 +2382,7 @@ export type GQLMutation = { readonly setModeratorSafetySettings?: Maybe; readonly setMrtChartConfigurationSettings?: Maybe; readonly setOrgDefaultSafetySettings?: Maybe; + readonly setPluginIntegrationConfig: GQLSetIntegrationConfigResponse; readonly signUp: GQLSignUpResponse; readonly submitManualReviewDecision: GQLSubmitDecisionResponse; readonly updateAccountInfo?: Maybe; @@ -2588,6 +2635,10 @@ export type GQLMutationSetOrgDefaultSafetySettingsArgs = { orgDefaultSafetySettings: GQLModeratorSafetySettingsInput; }; +export type GQLMutationSetPluginIntegrationConfigArgs = { + input: GQLSetPluginIntegrationConfigInput; +}; + export type GQLMutationSignUpArgs = { input: GQLSignUpInput; }; @@ -3037,6 +3088,11 @@ export type GQLPlaceBoundsInput = { readonly southwestCorner: GQLLatLngInput; }; +export type GQLPluginIntegrationApiCredential = { + readonly __typename?: 'PluginIntegrationApiCredential'; + readonly credential: Scalars['JSONObject']; +}; + export type GQLPolicy = { readonly __typename?: 'Policy'; readonly applyUserStrikeCountConfigToChildren?: Maybe; @@ -3105,6 +3161,7 @@ export type GQLQuery = { readonly allRuleInsights: GQLAllRuleInsights; readonly apiKey: Scalars['String']; readonly appealSettings?: Maybe; + readonly availableIntegrations: ReadonlyArray; readonly getCommentsForJob: ReadonlyArray; readonly getDecidedJob?: Maybe; readonly getDecidedJobFromJobId?: Maybe; @@ -3247,7 +3304,7 @@ export type GQLQueryHashBankByIdArgs = { }; export type GQLQueryIntegrationConfigArgs = { - name: GQLIntegration; + name: Scalars['String']; }; export type GQLQueryInviteUserTokenArgs = { @@ -3922,6 +3979,11 @@ export type GQLSetMrtChartConfigurationSettingsSuccessResponse = { readonly _?: Maybe; }; +export type GQLSetPluginIntegrationConfigInput = { + readonly credential: Scalars['JSONObject']; + readonly integrationId: Scalars['String']; +}; + export type GQLSetUserStrikeThresholdInput = { readonly actions: ReadonlyArray; readonly threshold: Scalars['Int']; @@ -3970,7 +4032,13 @@ export type GQLSignal = { readonly eligibleInputs: ReadonlyArray; readonly eligibleSubcategories: ReadonlyArray; readonly id: Scalars['ID']; - readonly integration?: Maybe; + readonly integration?: Maybe; + /** Logo URL for the integration. Null if not set or when signal has no integration. */ + readonly integrationLogoUrl?: Maybe; + /** Logo-with-background URL for the integration. Null if not set or when signal has no integration. */ + readonly integrationLogoWithBackgroundUrl?: Maybe; + /** Display name for the signal’s integration (from registry manifest). Null when signal has no integration. */ + readonly integrationTitle?: Maybe; readonly name: Scalars['String']; readonly outputType: GQLSignalOutputType; readonly pricingStructure: GQLSignalPricingStructure; @@ -3978,7 +4046,7 @@ export type GQLSignal = { readonly shouldPromptForMatchingValues: Scalars['Boolean']; readonly subcategory?: Maybe; readonly supportedLanguages: GQLSupportedLanguages; - readonly type: GQLSignalType; + readonly type: Scalars['String']; }; export type GQLSignalArgs = GQLAggregationSignalArgs; @@ -4074,7 +4142,7 @@ export const GQLSignalType = { export type GQLSignalType = (typeof GQLSignalType)[keyof typeof GQLSignalType]; export type GQLSignalWithScore = { readonly __typename?: 'SignalWithScore'; - readonly integration?: Maybe; + readonly integration?: Maybe; readonly score: Scalars['String']; readonly signalName: Scalars['String']; readonly subcategory?: Maybe; @@ -5196,6 +5264,7 @@ export type GQLResolversTypes = { IntegrationApiCredential: | GQLResolversTypes['GoogleContentSafetyApiIntegrationApiCredential'] | GQLResolversTypes['OpenAiIntegrationApiCredential'] + | GQLResolversTypes['PluginIntegrationApiCredential'] | GQLResolversTypes['ZentropiIntegrationApiCredential']; IntegrationApiCredentialInput: GQLIntegrationApiCredentialInput; IntegrationConfig: ResolverTypeWrapper< @@ -5210,6 +5279,7 @@ export type GQLResolversTypes = { IntegrationConfigTooManyCredentialsError: ResolverTypeWrapper; IntegrationConfigUnsupportedIntegrationError: ResolverTypeWrapper; IntegrationEmptyInputCredentialsError: ResolverTypeWrapper; + IntegrationMetadata: ResolverTypeWrapper; IntegrationNoInputCredentialsError: ResolverTypeWrapper; InviteUserInput: GQLInviteUserInput; InviteUserToken: ResolverTypeWrapper; @@ -5362,6 +5432,10 @@ export type GQLResolversTypes = { } >; MetricsTimeDivisionOptions: GQLMetricsTimeDivisionOptions; + ModelCard: ResolverTypeWrapper; + ModelCardField: ResolverTypeWrapper; + ModelCardSection: ResolverTypeWrapper; + ModelCardSubsection: ResolverTypeWrapper; ModeratorSafetySettingsInput: GQLModeratorSafetySettingsInput; MrtJobEnqueueSourceInfo: ResolverTypeWrapper; MutateAccessibleQueuesForUserSuccessResponse: ResolverTypeWrapper; @@ -5497,6 +5571,7 @@ export type GQLResolversTypes = { PendingInvite: ResolverTypeWrapper; PlaceBounds: ResolverTypeWrapper; PlaceBoundsInput: GQLPlaceBoundsInput; + PluginIntegrationApiCredential: ResolverTypeWrapper; Policy: ResolverTypeWrapper; PolicyActionCount: ResolverTypeWrapper; PolicyNameExistsError: ResolverTypeWrapper; @@ -5619,6 +5694,7 @@ export type GQLResolversTypes = { SetIntegrationConfigSuccessResponse: ResolverTypeWrapper; SetModeratorSafetySettingsSuccessResponse: ResolverTypeWrapper; SetMrtChartConfigurationSettingsSuccessResponse: ResolverTypeWrapper; + SetPluginIntegrationConfigInput: GQLSetPluginIntegrationConfigInput; SetUserStrikeThresholdInput: GQLSetUserStrikeThresholdInput; SignUpInput: GQLSignUpInput; SignUpResponse: @@ -6043,6 +6119,7 @@ export type GQLResolversParentTypes = { IntegrationApiCredential: | GQLResolversParentTypes['GoogleContentSafetyApiIntegrationApiCredential'] | GQLResolversParentTypes['OpenAiIntegrationApiCredential'] + | GQLResolversParentTypes['PluginIntegrationApiCredential'] | GQLResolversParentTypes['ZentropiIntegrationApiCredential']; IntegrationApiCredentialInput: GQLIntegrationApiCredentialInput; IntegrationConfig: Omit & { @@ -6055,6 +6132,7 @@ export type GQLResolversParentTypes = { IntegrationConfigTooManyCredentialsError: GQLIntegrationConfigTooManyCredentialsError; IntegrationConfigUnsupportedIntegrationError: GQLIntegrationConfigUnsupportedIntegrationError; IntegrationEmptyInputCredentialsError: GQLIntegrationEmptyInputCredentialsError; + IntegrationMetadata: GQLIntegrationMetadata; IntegrationNoInputCredentialsError: GQLIntegrationNoInputCredentialsError; InviteUserInput: GQLInviteUserInput; InviteUserToken: GQLInviteUserToken; @@ -6181,6 +6259,10 @@ export type GQLResolversParentTypes = { MessageWithIpAddress: Omit & { message: GQLResolversParentTypes['ContentItem']; }; + ModelCard: GQLModelCard; + ModelCardField: GQLModelCardField; + ModelCardSection: GQLModelCardSection; + ModelCardSubsection: GQLModelCardSubsection; ModeratorSafetySettingsInput: GQLModeratorSafetySettingsInput; MrtJobEnqueueSourceInfo: GQLMrtJobEnqueueSourceInfo; MutateAccessibleQueuesForUserSuccessResponse: GQLMutateAccessibleQueuesForUserSuccessResponse; @@ -6291,6 +6373,7 @@ export type GQLResolversParentTypes = { PendingInvite: GQLPendingInvite; PlaceBounds: GQLPlaceBounds; PlaceBoundsInput: GQLPlaceBoundsInput; + PluginIntegrationApiCredential: GQLPluginIntegrationApiCredential; Policy: GQLPolicy; PolicyActionCount: GQLPolicyActionCount; PolicyNameExistsError: GQLPolicyNameExistsError; @@ -6406,6 +6489,7 @@ export type GQLResolversParentTypes = { SetIntegrationConfigSuccessResponse: GQLSetIntegrationConfigSuccessResponse; SetModeratorSafetySettingsSuccessResponse: GQLSetModeratorSafetySettingsSuccessResponse; SetMrtChartConfigurationSettingsSuccessResponse: GQLSetMrtChartConfigurationSettingsSuccessResponse; + SetPluginIntegrationConfigInput: GQLSetPluginIntegrationConfigInput; SetUserStrikeThresholdInput: GQLSetUserStrikeThresholdInput; SignUpInput: GQLSignUpInput; SignUpResponse: @@ -8456,6 +8540,7 @@ export type GQLIntegrationApiCredentialResolvers< __resolveType: TypeResolveFn< | 'GoogleContentSafetyApiIntegrationApiCredential' | 'OpenAiIntegrationApiCredential' + | 'PluginIntegrationApiCredential' | 'ZentropiIntegrationApiCredential', ParentType, ContextType @@ -8472,7 +8557,30 @@ export type GQLIntegrationConfigResolvers< ParentType, ContextType >; - name?: Resolver; + docsUrl?: Resolver; + logoUrl?: Resolver< + Maybe, + ParentType, + ContextType + >; + logoWithBackgroundUrl?: Resolver< + Maybe, + ParentType, + ContextType + >; + modelCard?: Resolver; + modelCardLearnMoreUrl?: Resolver< + Maybe, + ParentType, + ContextType + >; + name?: Resolver; + requiresConfig?: Resolver< + GQLResolversTypes['Boolean'], + ParentType, + ContextType + >; + title?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; @@ -8592,6 +8700,32 @@ export type GQLIntegrationEmptyInputCredentialsErrorResolvers< __isTypeOf?: IsTypeOfResolverFn; }; +export type GQLIntegrationMetadataResolvers< + ContextType = Context, + ParentType extends + GQLResolversParentTypes['IntegrationMetadata'] = GQLResolversParentTypes['IntegrationMetadata'], +> = { + docsUrl?: Resolver; + logoUrl?: Resolver< + Maybe, + ParentType, + ContextType + >; + logoWithBackgroundUrl?: Resolver< + Maybe, + ParentType, + ContextType + >; + name?: Resolver; + requiresConfig?: Resolver< + GQLResolversTypes['Boolean'], + ParentType, + ContextType + >; + title?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type GQLIntegrationNoInputCredentialsErrorResolvers< ContextType = Context, ParentType extends @@ -9750,6 +9884,70 @@ export type GQLMessageWithIpAddressResolvers< __isTypeOf?: IsTypeOfResolverFn; }; +export type GQLModelCardResolvers< + ContextType = Context, + ParentType extends + GQLResolversParentTypes['ModelCard'] = GQLResolversParentTypes['ModelCard'], +> = { + modelName?: Resolver; + releaseDate?: Resolver< + Maybe, + ParentType, + ContextType + >; + sections?: Resolver< + Maybe>, + ParentType, + ContextType + >; + version?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type GQLModelCardFieldResolvers< + ContextType = Context, + ParentType extends + GQLResolversParentTypes['ModelCardField'] = GQLResolversParentTypes['ModelCardField'], +> = { + label?: Resolver; + value?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type GQLModelCardSectionResolvers< + ContextType = Context, + ParentType extends + GQLResolversParentTypes['ModelCardSection'] = GQLResolversParentTypes['ModelCardSection'], +> = { + fields?: Resolver< + Maybe>, + ParentType, + ContextType + >; + id?: Resolver; + subsections?: Resolver< + Maybe>, + ParentType, + ContextType + >; + title?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type GQLModelCardSubsectionResolvers< + ContextType = Context, + ParentType extends + GQLResolversParentTypes['ModelCardSubsection'] = GQLResolversParentTypes['ModelCardSubsection'], +> = { + fields?: Resolver< + ReadonlyArray, + ParentType, + ContextType + >; + title?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type GQLMrtJobEnqueueSourceInfoResolvers< ContextType = Context, ParentType extends @@ -10352,6 +10550,12 @@ export type GQLMutationResolvers< 'orgDefaultSafetySettings' > >; + setPluginIntegrationConfig?: Resolver< + GQLResolversTypes['SetIntegrationConfigResponse'], + ParentType, + ContextType, + RequireFields + >; signUp?: Resolver< GQLResolversTypes['SignUpResponse'], ParentType, @@ -11190,6 +11394,19 @@ export type GQLPlaceBoundsResolvers< __isTypeOf?: IsTypeOfResolverFn; }; +export type GQLPluginIntegrationApiCredentialResolvers< + ContextType = Context, + ParentType extends + GQLResolversParentTypes['PluginIntegrationApiCredential'] = GQLResolversParentTypes['PluginIntegrationApiCredential'], +> = { + credential?: Resolver< + GQLResolversTypes['JSONObject'], + ParentType, + ContextType + >; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type GQLPolicyResolvers< ContextType = Context, ParentType extends @@ -11337,6 +11554,11 @@ export type GQLQueryResolvers< ParentType, ContextType >; + availableIntegrations?: Resolver< + ReadonlyArray, + ParentType, + ContextType + >; getCommentsForJob?: Resolver< ReadonlyArray, ParentType, @@ -12712,7 +12934,22 @@ export type GQLSignalResolvers< >; id?: Resolver; integration?: Resolver< - Maybe, + Maybe, + ParentType, + ContextType + >; + integrationLogoUrl?: Resolver< + Maybe, + ParentType, + ContextType + >; + integrationLogoWithBackgroundUrl?: Resolver< + Maybe, + ParentType, + ContextType + >; + integrationTitle?: Resolver< + Maybe, ParentType, ContextType >; @@ -12747,7 +12984,7 @@ export type GQLSignalResolvers< ParentType, ContextType >; - type?: Resolver; + type?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; @@ -12814,7 +13051,7 @@ export type GQLSignalWithScoreResolvers< GQLResolversParentTypes['SignalWithScore'] = GQLResolversParentTypes['SignalWithScore'], > = { integration?: Resolver< - Maybe, + Maybe, ParentType, ContextType >; @@ -13993,6 +14230,7 @@ export type GQLResolvers = { IntegrationConfigTooManyCredentialsError?: GQLIntegrationConfigTooManyCredentialsErrorResolvers; IntegrationConfigUnsupportedIntegrationError?: GQLIntegrationConfigUnsupportedIntegrationErrorResolvers; IntegrationEmptyInputCredentialsError?: GQLIntegrationEmptyInputCredentialsErrorResolvers; + IntegrationMetadata?: GQLIntegrationMetadataResolvers; IntegrationNoInputCredentialsError?: GQLIntegrationNoInputCredentialsErrorResolvers; InviteUserToken?: GQLInviteUserTokenResolvers; InviteUserTokenExpiredError?: GQLInviteUserTokenExpiredErrorResolvers; @@ -14047,6 +14285,10 @@ export type GQLResolvers = { MatchingBanks?: GQLMatchingBanksResolvers; MatchingValues?: GQLMatchingValuesResolvers; MessageWithIpAddress?: GQLMessageWithIpAddressResolvers; + ModelCard?: GQLModelCardResolvers; + ModelCardField?: GQLModelCardFieldResolvers; + ModelCardSection?: GQLModelCardSectionResolvers; + ModelCardSubsection?: GQLModelCardSubsectionResolvers; MrtJobEnqueueSourceInfo?: GQLMrtJobEnqueueSourceInfoResolvers; MutateAccessibleQueuesForUserSuccessResponse?: GQLMutateAccessibleQueuesForUserSuccessResponseResolvers; MutateActionResponse?: GQLMutateActionResponseResolvers; @@ -14093,6 +14335,7 @@ export type GQLResolvers = { PartialItemsSuccessResponse?: GQLPartialItemsSuccessResponseResolvers; PendingInvite?: GQLPendingInviteResolvers; PlaceBounds?: GQLPlaceBoundsResolvers; + PluginIntegrationApiCredential?: GQLPluginIntegrationApiCredentialResolvers; Policy?: GQLPolicyResolvers; PolicyActionCount?: GQLPolicyActionCountResolvers; PolicyNameExistsError?: GQLPolicyNameExistsErrorResolvers; diff --git a/server/graphql/modules/insights.ts b/server/graphql/modules/insights.ts index e5da24b..3fbe270 100644 --- a/server/graphql/modules/insights.ts +++ b/server/graphql/modules/insights.ts @@ -23,7 +23,7 @@ const typeDefs = /* GraphQL */ ` type SignalWithScore { signalName: String! - integration: Integration + integration: String subcategory: String score: String! } diff --git a/server/graphql/modules/integration.ts b/server/graphql/modules/integration.ts index ed8e015..829153b 100644 --- a/server/graphql/modules/integration.ts +++ b/server/graphql/modules/integration.ts @@ -1,14 +1,15 @@ import { AuthenticationError } from 'apollo-server-express'; -import { isConfigurableIntegration } from '../../services/signalAuthService/index.js'; +import { getIntegrationRegistry } from '../../services/integrationRegistry/index.js'; import { Integration } from '../../services/signalsService/index.js'; import { isCoopErrorOfType } from '../../utils/errors.js'; -import { assertUnreachable } from '../../utils/misc.js'; import { makeIntegrationConfigUnsupportedIntegrationError, - type TIntegrationCredential, } from '../datasources/IntegrationApi.js'; +import type { TIntegrationConfigWithMetadata } from '../datasources/IntegrationApi.js'; import { + type GQLIntegrationConfig, + type GQLIntegrationMetadata, type GQLMutationResolvers, type GQLQueryResolvers, } from '../generated.js'; @@ -50,10 +51,55 @@ const typeDefs = /* GraphQL */ ` GoogleContentSafetyApiIntegrationApiCredential | OpenAiIntegrationApiCredential | ZentropiIntegrationApiCredential + | PluginIntegrationApiCredential + + type ModelCardField { + label: String! + value: String! + } + + type ModelCardSubsection { + title: String! + fields: [ModelCardField!]! + } + + type ModelCardSection { + id: String! + title: String! + subsections: [ModelCardSubsection!] + fields: [ModelCardField!] + } + + type ModelCard { + modelName: String! + version: String! + releaseDate: String + sections: [ModelCardSection!] + } + + type IntegrationMetadata { + name: String! + title: String! + docsUrl: String! + requiresConfig: Boolean! + logoUrl: String + logoWithBackgroundUrl: String + } + + type PluginIntegrationApiCredential { + credential: JSONObject! + } type IntegrationConfig { - name: Integration! + name: String! apiCredential: IntegrationApiCredential! + modelCard: ModelCard! + modelCardLearnMoreUrl: String + title: String! + docsUrl: String! + requiresConfig: Boolean! + logoUrl: String + logoWithBackgroundUrl: String } input GoogleContentSafetyApiIntegrationApiCredentialInput { @@ -139,19 +185,28 @@ const typeDefs = /* GraphQL */ ` | IntegrationConfigUnsupportedIntegrationError type Query { - integrationConfig(name: Integration!): IntegrationConfigQueryResponse! + integrationConfig(name: String!): IntegrationConfigQueryResponse! + availableIntegrations: [IntegrationMetadata!]! + } + + input SetPluginIntegrationConfigInput { + integrationId: String! + credential: JSONObject! } type Mutation { setIntegrationConfig( input: SetIntegrationConfigInput! ): SetIntegrationConfigResponse! + setPluginIntegrationConfig( + input: SetPluginIntegrationConfigInput! + ): SetIntegrationConfigResponse! } `; -const IntegrationApiCredential: ResolverMap = { +const IntegrationApiCredential: ResolverMap = { __resolveType(it) { - const integrationName = it.name; + const integrationName = (it as { name?: string }).name ?? ''; switch (integrationName) { case Integration.GOOGLE_CONTENT_SAFETY_API: return 'GoogleContentSafetyApiIntegrationApiCredential'; @@ -160,11 +215,7 @@ const IntegrationApiCredential: ResolverMap = { case Integration.ZENTROPI: return 'ZentropiIntegrationApiCredential'; default: - // TypeScript can't verify exhaustiveness here because GQL enum includes - assertUnreachable( - integrationName, - `Unsupported integration: ${integrationName}`, - ); + return 'PluginIntegrationApiCredential'; } }, }; @@ -177,18 +228,22 @@ const Query: GQLQueryResolvers = { throw new AuthenticationError('Unauthenticated User'); } - if (!isConfigurableIntegration(name)) { + if (!getIntegrationRegistry().has(name)) { throw makeIntegrationConfigUnsupportedIntegrationError({ shouldErrorSpan: true, }); } - const config = await context.dataSources.integrationAPI.getConfig( - user.orgId, - name, - ); + const config = + await context.dataSources.integrationAPI.getConfigWithMetadata( + user.orgId, + name, + ); - return gqlSuccessResult({ config }, 'IntegrationConfigSuccessResult'); + return gqlSuccessResult( + { config: config as GQLIntegrationConfig }, + 'IntegrationConfigSuccessResult', + ); } catch (e: unknown) { if ( isCoopErrorOfType(e, 'IntegrationConfigUnsupportedIntegrationError') @@ -199,6 +254,19 @@ const Query: GQLQueryResolvers = { throw e; } }, + async availableIntegrations(_, __, context) { + const user = context.getUser(); + if (user == null) { + throw new AuthenticationError('Unauthenticated User'); + } + return context.dataSources.integrationAPI.getAvailableIntegrations() as GQLIntegrationMetadata[]; + }, +}; + +const PluginIntegrationApiCredential = { + credential(it: TIntegrationConfigWithMetadata['apiCredential']) { + return it as Record; + }, }; const Mutation: GQLMutationResolvers = { @@ -214,7 +282,38 @@ const Mutation: GQLMutationResolvers = { ); return gqlSuccessResult( - { config: newConfig }, + { config: newConfig as GQLIntegrationConfig }, + 'SetIntegrationConfigSuccessResponse', + ); + } catch (e: unknown) { + if ( + isCoopErrorOfType(e, [ + 'IntegrationConfigTooManyCredentialsError', + 'IntegrationNoInputCredentialsError', + 'IntegrationEmptyInputCredentialsError', + ]) + ) { + return gqlErrorResult(e); + } + + throw e; + } + }, + async setPluginIntegrationConfig(_, params, context) { + try { + const user = context.getUser(); + if (user == null) { + throw new AuthenticationError('Unauthenticated User'); + } + const newConfig = + await context.dataSources.integrationAPI.setConfigByIntegrationId( + params.input.integrationId, + params.input.credential as Record, + user.orgId, + ); + + return gqlSuccessResult( + { config: newConfig as GQLIntegrationConfig }, 'SetIntegrationConfigSuccessResponse', ); } catch (e: unknown) { @@ -235,6 +334,7 @@ const Mutation: GQLMutationResolvers = { const resolvers = { IntegrationApiCredential, + PluginIntegrationApiCredential, Query, Mutation, }; diff --git a/server/graphql/modules/org.ts b/server/graphql/modules/org.ts index 104158a..196e96e 100644 --- a/server/graphql/modules/org.ts +++ b/server/graphql/modules/org.ts @@ -4,6 +4,7 @@ import { AuthenticationError } from 'apollo-server-express'; import { isCoopErrorOfType } from '../../utils/errors.js'; import { __throw } from '../../utils/misc.js'; import { + type GQLIntegrationConfig, type GQLMatchingBanksResolvers, type GQLMutationResolvers, type GQLOrgResolvers, @@ -327,7 +328,9 @@ const Org: GQLOrgResolvers = { throw new AuthenticationError('User required.'); } - return context.dataSources.integrationAPI.getAllIntegrationConfigs(org.id); + return context.dataSources.integrationAPI.getAllIntegrationConfigs( + org.id, + ) as Promise; }, // customOnly param fetches only the org's custom signals async signals(org, { customOnly }, context) { diff --git a/server/graphql/modules/rule.ts b/server/graphql/modules/rule.ts index d706c5a..3891a19 100644 --- a/server/graphql/modules/rule.ts +++ b/server/graphql/modules/rule.ts @@ -371,7 +371,7 @@ const typeDefs = /* GraphQL */ ` input ConditionInputSignalInput { id: ID! # JsonOf - type: SignalType! + type: String! name: String subcategory: String args: SignalArgsInput diff --git a/server/graphql/modules/signal.ts b/server/graphql/modules/signal.ts index ff02309..3d552fb 100644 --- a/server/graphql/modules/signal.ts +++ b/server/graphql/modules/signal.ts @@ -2,17 +2,17 @@ import { type SignalSubcategory } from '@roostorg/types'; import { AuthenticationError } from 'apollo-server-express'; import { type ReadonlyDeep } from 'type-fest'; +import { getIntegrationRegistry } from '../../services/integrationRegistry/index.js'; import { getSignalIdString, - type ExternalSignalType, type SignalOutputType as TSignalOutputType, } from '../../services/signalsService/index.js'; import { safePick } from '../../utils/misc.js'; import { type GQLLanguage, type GQLSignalArgsResolvers, + type GQLSignalPricingStructure, type GQLSignalResolvers, - type GQLSignalType, type GQLSupportedLanguagesResolvers, } from '../generated.js'; import { type ResolverMap } from '../resolvers.js'; @@ -52,8 +52,14 @@ const typeDefs = /* GraphQL */ ` type Signal { id: ID! # JsonOf - type: SignalType! - integration: Integration + type: String! + integration: String + """Display name for the signal’s integration (from registry manifest). Null when signal has no integration.""" + integrationTitle: String + """Logo URL for the integration. Null if not set or when signal has no integration.""" + integrationLogoUrl: String + """Logo-with-background URL for the integration. Null if not set or when signal has no integration.""" + integrationLogoWithBackgroundUrl: String name: String! description: String! docsUrl: String @@ -189,18 +195,34 @@ const Signal: GQLSignalResolvers = { id(signal) { return getSignalIdString(signal.id); }, - // NB: This resolver is unnecessary from a runtime POV (its functionality is - // the same as the default resolver), but we keep it for type checking (i.e., - // it verifies that our ExternalSignalType is assignable to the GQLSignalType - // that we're supposed to be returning). - type(signal): GQLSignalType { - return signal.type as ExternalSignalType; + // type is String! to support plugin signal types (e.g. RANDOM_SIGNAL_SELECTION) + // in addition to built-in ExternalSignalType values. + type(signal): string { + return signal.type; + }, + integrationTitle(signal) { + if (signal.integration == null) return null; + return getIntegrationRegistry().getManifest(signal.integration)?.title ?? null; + }, + integrationLogoUrl(signal) { + if (signal.integration == null) return null; + return getIntegrationRegistry().getManifest(signal.integration)?.logoUrl ?? null; + }, + integrationLogoWithBackgroundUrl(signal) { + if (signal.integration == null) return null; + return getIntegrationRegistry().getManifest(signal.integration)?.logoWithBackgroundUrl ?? null; }, name(signal) { return signal.displayName; }, - pricingStructure(signal) { - return { type: signal.pricingStructure }; + pricingStructure(signal): GQLSignalPricingStructure { + const ps = signal.pricingStructure as + | { type: string } + | string; + if (typeof ps === 'object' && 'type' in ps) { + return ps as GQLSignalPricingStructure; + } + return { type: ps as GQLSignalPricingStructure['type'] }; }, async disabledInfo(signal, _, context) { const user = context.getUser(); @@ -237,7 +259,10 @@ const Signal: GQLSignalResolvers = { 'ZENTROPI', ); if (config?.name === 'ZENTROPI') { - const versions = config.apiCredential.labelerVersions ?? []; + const versions = (config.apiCredential.labelerVersions ?? []) as Array<{ + id: string; + label: string; + }>; return versions.map((v) => ({ id: v.id, label: v.label, diff --git a/server/integrations.config.json b/server/integrations.config.json new file mode 100644 index 0000000..7b12d5f --- /dev/null +++ b/server/integrations.config.json @@ -0,0 +1,8 @@ +{ + "integrations": [ + { + "package": "@roostorg/coop-integration-example", + "enabled": true + } + ] +} diff --git a/server/package-lock.json b/server/package-lock.json index 21b207e..a93aa18 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -24,7 +24,8 @@ "@node-saml/passport-saml": "^5.1.0", "@opentelemetry/api": "^1.8.0", "@opentelemetry/semantic-conventions": "^1.22.0", - "@roostorg/types": "^1.0.49", + "@roostorg/coop-integration-example": "^1.0.0", + "@roostorg/types": "^1.1.1", "@sendgrid/mail": "^8.1.6", "@stdlib/stats-binomial-test": "^0.0.7", "@total-typescript/ts-reset": "^0.3.7", @@ -3953,10 +3954,22 @@ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" }, + "node_modules/@roostorg/coop-integration-example": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@roostorg/coop-integration-example/-/coop-integration-example-1.0.0.tgz", + "integrity": "sha512-vzb5sXSJHtg/tJxHOj0WbgVjf9ZKrul9zd1QsUGEScoEkHzybjISuELDM3F3IrUJMIdI+c5ISGto16VsC4cpCw==", + "license": "apache-2.0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@roostorg/types": ">=1.0.0" + } + }, "node_modules/@roostorg/types": { - "version": "1.0.49", - "resolved": "https://registry.npmjs.org/@roostorg/types/-/types-1.0.49.tgz", - "integrity": "sha512-yrilSnPzP/KPryazRQbufMuwfBTLnne08TdkZqUW2QObh6oHkyDGwShQSLkWqRzKJxo+VSFZlbHGXdVUeGS3LQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@roostorg/types/-/types-1.1.1.tgz", + "integrity": "sha512-NhPYlG27wAQaD7AzWkL3LJHu52/QfK8lt9QMahUx7fbRtB4fYILy4fGcLQvt45gNQANoU78evW1UJftAB0B89Q==", "license": "ISC", "dependencies": { "date-fns": "^2.29.3", diff --git a/server/package.json b/server/package.json index 1580a20..9828ae7 100644 --- a/server/package.json +++ b/server/package.json @@ -7,7 +7,7 @@ "build": "tsc && npm run copy-assets", "copy-assets": "copyfiles \"lib/**/*.lua\" transpiled/", "start": "tsc-watch --onSuccess \"node --require dotenv/config ./transpiled/bin/www.js\"", - "start:trace": "tsc-watch --onSuccess \"node --require dotenv/config --require ../nodejs-instrumentation/transpiled/autoinstrumentation.js ./transpiled/bin/www.js\"", + "start:trace": "tsc-watch --onSuccess \"node --trace-warnings --require dotenv/config --require ../nodejs-instrumentation/transpiled/autoinstrumentation.js ./transpiled/bin/www.js\"", "test": "npm run test:local", "test:local": "NODE_OPTIONS=\"--no-warnings --loader ts-node/esm --require dotenv/config\" jest --watch --detectOpenHandles", "test:prepush": "NODE_OPTIONS=\"--no-warnings --loader ts-node/esm --require dotenv/config\" jest --detectOpenHandles --no-cache --forceExit", @@ -38,7 +38,8 @@ "@node-saml/passport-saml": "^5.1.0", "@opentelemetry/api": "^1.8.0", "@opentelemetry/semantic-conventions": "^1.22.0", - "@roostorg/types": "^1.0.49", + "@roostorg/coop-integration-example": "^1.0.0", + "@roostorg/types": "^1.1.1", "@sendgrid/mail": "^8.1.6", "@stdlib/stats-binomial-test": "^0.0.7", "@total-typescript/ts-reset": "^0.3.7", diff --git a/server/routes/index.ts b/server/routes/index.ts index 5613249..51748c8 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -2,16 +2,21 @@ import { type Route } from '../utils/route-helpers.js'; import ActionRoutes from './action/ActionRoutes.js'; import ContentRoutes from './content/ContentRoutes.js'; import GDPRRoutes from './gdpr/gdprRoutes.js'; +import IntegrationLogosRoutes from './integration_logos/IntegrationLogosRoutes.js'; import ItemRoutes from './items/ItemRoutes.js'; import PoliciesRoutes from './policies/PoliciesRoutes.js'; import ReportingRoutes from './reporting/ReportingRoutes.js'; import UserScoresRoutes from './user_scores/UserScoresRoutes.js'; +/** Array of routes accepted by a controller. Uses wide types so GET (no body) and POST routes both fit. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Controller accepts any route shape +export type ControllerRouteList = Route[]; + export type Controller = { // Path prefix expected to always start with a slash, given how we're // concatenating it with `/api/v1` in our server setup. pathPrefix: `/${string}`; - routes: Route[]; + routes: ControllerRouteList; }; // eslint-disable-next-line @typescript-eslint/consistent-type-assertions @@ -23,4 +28,5 @@ export default { UserScores: UserScoresRoutes, Actions: ActionRoutes, GDPR: GDPRRoutes, + IntegrationLogos: IntegrationLogosRoutes, } satisfies { [key: string]: Controller }; diff --git a/server/routes/integration_logos/IntegrationLogosRoutes.ts b/server/routes/integration_logos/IntegrationLogosRoutes.ts new file mode 100644 index 0000000..6abe8e0 --- /dev/null +++ b/server/routes/integration_logos/IntegrationLogosRoutes.ts @@ -0,0 +1,12 @@ +import { route } from '../../utils/route-helpers.js'; +import { type Controller, type ControllerRouteList } from '../index.js'; +import serveIntegrationLogo from './serveIntegrationLogo.js'; +import serveIntegrationLogoWithBackground from './serveIntegrationLogoWithBackground.js'; + +export default { + pathPrefix: '/integration-logos', + routes: [ + route.get('/:integrationId/with-background', serveIntegrationLogoWithBackground), + route.get('/:integrationId', serveIntegrationLogo), + ] as ControllerRouteList, +} satisfies Controller; diff --git a/server/routes/integration_logos/serveIntegrationLogo.ts b/server/routes/integration_logos/serveIntegrationLogo.ts new file mode 100644 index 0000000..eb4680a --- /dev/null +++ b/server/routes/integration_logos/serveIntegrationLogo.ts @@ -0,0 +1,37 @@ +import { type Dependencies } from '../../iocContainer/index.js'; +import { getIntegrationRegistry } from '../../services/integrationRegistry/index.js'; +import { makeNotFoundError } from '../../utils/errors.js'; +import { type RequestHandlerWithBodies } from '../../utils/route-helpers.js'; + +/** + * GET /integration-logos/:integrationId — serves the plugin logo file when + * the integration manifest sets logoPath. Returns 404 if the integration + * has no logo or logoPath was not set. + */ +export default function serveIntegrationLogo( + _deps: Dependencies, +): RequestHandlerWithBodies, undefined> { + return (req, res, next) => { + const integrationId = req.params['integrationId']; + if (!integrationId || integrationId.length === 0) { + return next( + makeNotFoundError('Missing integration id.', { shouldErrorSpan: true }), + ); + } + const filePath = getIntegrationRegistry().getPluginLogoFilePath(integrationId); + if (filePath === undefined) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition -- runtime guard for missing plugin logo + return next( + makeNotFoundError('Integration logo not found.', { + shouldErrorSpan: true, + }), + ); + } + // Path was validated at plugin load (under package root); safe to send. + res.setHeader('Cache-Control', 'public, max-age=86400'); + res.sendFile(filePath, (err) => { + if (err != null && !res.headersSent) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition -- sendFile callback err is Error | null per types + next(err); + } + }); + }; +} diff --git a/server/routes/integration_logos/serveIntegrationLogoWithBackground.ts b/server/routes/integration_logos/serveIntegrationLogoWithBackground.ts new file mode 100644 index 0000000..e4aecd5 --- /dev/null +++ b/server/routes/integration_logos/serveIntegrationLogoWithBackground.ts @@ -0,0 +1,35 @@ +import { type Dependencies } from '../../iocContainer/index.js'; +import { getIntegrationRegistry } from '../../services/integrationRegistry/index.js'; +import { makeNotFoundError } from '../../utils/errors.js'; +import { type RequestHandlerWithBodies } from '../../utils/route-helpers.js'; + +/** + * GET /integration-logos/:integrationId/with-background — serves the plugin + * "with background" logo when the manifest sets logoWithBackgroundPath. + */ +export default function serveIntegrationLogoWithBackground( + _deps: Dependencies, +): RequestHandlerWithBodies, undefined> { + return (req, res, next) => { + const integrationId = req.params['integrationId']; + if (!integrationId || integrationId.length === 0) { + return next( + makeNotFoundError('Missing integration id.', { shouldErrorSpan: true }), + ); + } + const filePath = getIntegrationRegistry().getPluginLogoWithBackgroundFilePath(integrationId); + if (filePath === undefined) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition -- runtime guard for missing plugin logo + return next( + makeNotFoundError('Integration logo (with-background) not found.', { + shouldErrorSpan: true, + }), + ); + } + res.setHeader('Cache-Control', 'public, max-age=86400'); + res.sendFile(filePath, (err) => { + if (err != null && !res.headersSent) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition -- sendFile callback err is Error | null per types + next(err); + } + }); + }; +} diff --git a/server/services/analyticsQueries/RuleActionInsights.ts b/server/services/analyticsQueries/RuleActionInsights.ts index eb31f79..a4c2622 100644 --- a/server/services/analyticsQueries/RuleActionInsights.ts +++ b/server/services/analyticsQueries/RuleActionInsights.ts @@ -10,10 +10,10 @@ import { type RuleEnvironment } from '../../rule_engine/RuleEngine.js'; import { type NormalizedItemData } from '../../services/itemProcessingService/index.js'; import { BuiltInThirdPartySignalType, + SignalType, UserCreatedExternalSignalType, integrationForSignalType, type Integration, - type SignalType, } from '../../services/signalsService/index.js'; import { jsonParse, type JsonOf } from '../../utils/encoding.js'; import { @@ -476,7 +476,7 @@ type GatherSignalsConditionWithResult = type GatherSignalsLeafConditionWithResult = { signal?: { name: string; - type: SignalType; + type: string; subcategory?: string | null; } | null; result?: { score?: string | null } | null; @@ -486,9 +486,11 @@ type GatherSignalsConditionSetWithResult = { conditions: GatherSignalsConditionWithResult[]; }; -const signalResultShouldBeDisplayed = (type: SignalType) => +/** Includes built-in third-party, user-created, and plugin signal types (string). */ +const signalResultShouldBeDisplayed = (type: string) => Object.hasOwn(BuiltInThirdPartySignalType, type) || - Object.hasOwn(UserCreatedExternalSignalType, type); + Object.hasOwn(UserCreatedExternalSignalType, type) || + !Object.hasOwn(SignalType, type); // plugin signal types not in SignalType enum /** * When we display signal results in the UI (specifically in the rule samples diff --git a/server/services/integrationRegistry/index.ts b/server/services/integrationRegistry/index.ts new file mode 100644 index 0000000..066e81d --- /dev/null +++ b/server/services/integrationRegistry/index.ts @@ -0,0 +1,113 @@ +/** + * Dynamic integration registry: built-in manifests + plugins loaded from + * integrations.config.json. Single source of truth for available integrations. + */ + +import { + BUILT_IN_MANIFESTS, + type AvailableIntegration, + type IntegrationManifestEntry, +} from './integrationManifests.js'; +import { + getIntegrationsConfigPath, + loadIntegrationsConfig, +} from './loadIntegrationsConfig.js'; +import { loadPlugins, type PluginEntry } from './loadPlugins.js'; + +export type IntegrationRegistry = Readonly<{ + getManifest(id: string): IntegrationManifestEntry | undefined; + getAvailableIntegrations(): AvailableIntegration[]; + has(id: string): boolean; + getConfigurableIds(): readonly string[]; + /** Plugin (packageSpec, integrationId) entries for loading plugin signals. */ + getPluginEntries(): readonly PluginEntry[]; + /** Absolute path to main plugin logo (manifest.logoPath). */ + getPluginLogoFilePath(integrationId: string): string | undefined; + /** Absolute path to "with background" logo (manifest.logoWithBackgroundPath), if set. */ + getPluginLogoWithBackgroundFilePath(integrationId: string): string | undefined; +}>; + +function buildRegistry(): IntegrationRegistry { + const config = loadIntegrationsConfig(); + const configPath = getIntegrationsConfigPath(); + let result: ReturnType; + try { + result = loadPlugins(config, configPath); + } catch (err) { + throw new Error( + `Integration plugin loading failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + const map = new Map(); + for (const [id, entry] of Object.entries(BUILT_IN_MANIFESTS)) { + map.set(id, entry); + } + for (const [id, entry] of result.manifests) { + map.set(id, entry); + } + const configurableIds = Array.from(map.keys()); + const pluginEntries = result.pluginEntries; + const pluginLogoPaths = result.pluginLogoPaths; + const pluginLogoWithBackgroundPaths = result.pluginLogoWithBackgroundPaths; + + return { + getManifest(id: string): IntegrationManifestEntry | undefined { + return map.get(id); + }, + getAvailableIntegrations(): AvailableIntegration[] { + return configurableIds.map((name) => { + const manifest = map.get(name)!; + return { + name, + title: manifest.title, + docsUrl: manifest.docsUrl, + requiresConfig: manifest.requiresConfig, + logoUrl: manifest.logoUrl, + logoWithBackgroundUrl: manifest.logoWithBackgroundUrl, + }; + }); + }, + has(id: string): boolean { + return map.has(id); + }, + getConfigurableIds(): readonly string[] { + return configurableIds; + }, + getPluginEntries(): readonly PluginEntry[] { + return pluginEntries; + }, + getPluginLogoFilePath(integrationId: string): string | undefined { + return pluginLogoPaths.get(integrationId); + }, + getPluginLogoWithBackgroundFilePath(integrationId: string): string | undefined { + return pluginLogoWithBackgroundPaths.get(integrationId); + }, + }; +} + +let cachedRegistry: IntegrationRegistry | null = null; + +/** + * Returns the integration registry (built once on first call). + */ +export function getIntegrationRegistry(): IntegrationRegistry { + if (cachedRegistry == null) { + cachedRegistry = buildRegistry(); + } + return cachedRegistry; +} + +export { + BUILT_IN_MANIFESTS, + type AvailableIntegration, + type IntegrationManifestEntry, + type ModelCard, + type ModelCardField, + type ModelCardSection, + type ModelCardSubsection, +} from './integrationManifests.js'; +export { + getIntegrationsConfigPath, + loadIntegrationsConfig, +} from './loadIntegrationsConfig.js'; +export { loadPlugins } from './loadPlugins.js'; diff --git a/server/services/integrationRegistry/integrationManifests.ts b/server/services/integrationRegistry/integrationManifests.ts new file mode 100644 index 0000000..14976a8 --- /dev/null +++ b/server/services/integrationRegistry/integrationManifests.ts @@ -0,0 +1,277 @@ +/** + * Backend manifest entries for built-in integrations. + * The dynamic integration registry merges these with loaded plugins. + * Lives in the registry (not graphql) so transport-agnostic code can import it. + */ + +const REQUIRED_SECTION_IDS = ['modelDetails', 'technicalIntegration'] as const; + +export type ModelCardField = Readonly<{ label: string; value: string }>; +export type ModelCardSubsection = Readonly<{ + title: string; + fields: readonly ModelCardField[]; +}>; +export type ModelCardSection = Readonly<{ + id: string; + title: string; + subsections?: readonly ModelCardSubsection[]; + fields?: readonly ModelCardField[]; +}>; +export type ModelCard = Readonly<{ + modelName: string; + version: string; + releaseDate?: string; + sections?: readonly ModelCardSection[]; +}>; + +export type IntegrationManifestEntry = Readonly<{ + modelCard: ModelCard; + modelCardLearnMoreUrl?: string; + /** Display name for the integration (e.g. "Google Content Safety API"). */ + title: string; + /** Link to documentation or product page. */ + docsUrl: string; + /** Whether the integration requires the user to supply config (e.g. API key or other settings). */ + requiresConfig: boolean; + /** Optional URL to a logo image. When absent, client may use a fallback. */ + logoUrl?: string; + /** Optional URL to a logo variant (e.g. with background). */ + logoWithBackgroundUrl?: string; +}>; + +function assertModelCardHasRequiredSections(card: ModelCard): void { + const sectionIds = new Set((card.sections ?? []).map((s) => s.id)); + for (const requiredId of REQUIRED_SECTION_IDS) { + if (!sectionIds.has(requiredId)) { + throw new Error( + `Model card must include a section with id "${requiredId}".`, + ); + } + } +} + +const GOOGLE_CONTENT_SAFETY: IntegrationManifestEntry = { + modelCard: { + modelName: 'Content Safety API', + version: '1.x', + releaseDate: 'Ongoing', + sections: [ + { + id: 'modelDetails', + title: 'Model Details', + subsections: [ + { + title: 'Basic Information', + fields: [ + { label: 'Model Name', value: 'Content Safety API' }, + { label: 'Developed By', value: 'Google' }, + { + label: 'Documentation URL', + value: 'https://protectingchildren.google/tools-for-partners/', + }, + ], + }, + { + title: 'Intended Use', + fields: [ + { + label: 'Primary Use Case', + value: + 'Child safety prioritization recommendations on user-generated content.', + }, + { + label: 'Target Users', + value: 'Platforms and partners conducting content moderation.', + }, + { + label: 'Important Note', + value: + 'Users must conduct their own manual review and comply with applicable reporting laws. The API does not replace human judgment.', + }, + ], + }, + ], + }, + { + id: 'technicalIntegration', + title: 'Technical Integration', + fields: [ + { + label: 'Authentication', + value: 'API key (apply via Google\'s partner tools).', + }, + { + label: 'Integration Points', + value: + 'Coop sends content to the API and uses the returned prioritization in moderation workflows.', + }, + ], + }, + ], + }, + modelCardLearnMoreUrl: 'https://modelcards.withgoogle.com/', + title: 'Google Content Safety API', + docsUrl: 'https://protectingchildren.google/tools-for-partners/', + requiresConfig: true, +}; + +const OPENAI: IntegrationManifestEntry = { + modelCard: { + modelName: 'OpenAI', + version: 'v0.0', + releaseDate: 'January 2026', + sections: [ + { + id: 'modelDetails', + title: 'Model Details', + subsections: [ + { + title: 'Basic Information', + fields: [ + { label: 'Model Name', value: 'OpenAI' }, + { label: 'Version', value: 'v0.0' }, + { label: 'Release Date', value: 'January 2026' }, + { label: 'License Type', value: 'API Access Only' }, + { + label: 'Documentation URL', + value: 'https://platform.openai.com/docs', + }, + ], + }, + { + title: 'Model Architecture', + fields: [ + { label: 'Base Architecture', value: 'Transformer-based' }, + { + label: 'Input/output specifications', + value: + 'API-dependent; see OpenAI documentation for the specific model in use.', + }, + ], + }, + { + title: 'Intended Use', + fields: [ + { + label: 'Primary Use Case', + value: + 'Content moderation and safety-related classification via OpenAI APIs.', + }, + { + label: 'Target Users', + value: 'Platforms using Coop for moderation.', + }, + { + label: 'Deployment Context', + value: 'Used within Coop to call OpenAI APIs with your API key.', + }, + ], + }, + ], + }, + { + id: 'technicalIntegration', + title: 'Technical Integration', + fields: [ + { + label: 'Credentials', + value: 'This integration requires one API Key.', + }, + { + label: 'Documentation', + value: 'https://platform.openai.com/docs', + }, + ], + }, + ], + }, + modelCardLearnMoreUrl: 'https://modelcards.withgoogle.com/', + title: 'OpenAI', + docsUrl: 'https://platform.openai.com/docs', + requiresConfig: true, +}; + +const ZENTROPI: IntegrationManifestEntry = { + modelCard: { + modelName: 'Zentropi', + version: '1.x', + releaseDate: 'Ongoing', + sections: [ + { + id: 'modelDetails', + title: 'Model Details', + subsections: [ + { + title: 'Basic Information', + fields: [ + { label: 'Model Name', value: 'Zentropi' }, + { label: 'Developed By', value: 'Zentropi' }, + { + label: 'Documentation URL', + value: 'https://docs.zentropi.ai', + }, + ], + }, + { + title: 'Intended Use', + fields: [ + { + label: 'Primary Use Case', + value: + 'Content labeling and moderation via configurable labeler versions.', + }, + { + label: 'Target Users', + value: 'Platforms using Coop with Zentropi labelers.', + }, + { + label: 'Integration Points', + value: + 'API key plus optional labeler versions (id and label) for each model you use.', + }, + ], + }, + ], + }, + { + id: 'technicalIntegration', + title: 'Technical Integration', + fields: [ + { + label: 'Credentials', + value: + 'API Key plus optional Labeler Versions (id and label per version).', + }, + { label: 'Documentation', value: 'https://docs.zentropi.ai' }, + ], + }, + ], + }, + modelCardLearnMoreUrl: 'https://modelcards.withgoogle.com/', + title: 'Zentropi', + docsUrl: 'https://docs.zentropi.ai', + requiresConfig: true, +}; + +/** Built-in integration manifests (id -> entry). Merged with loaded plugins by the integration registry. */ +export const BUILT_IN_MANIFESTS: Readonly< + Record +> = { + GOOGLE_CONTENT_SAFETY_API: GOOGLE_CONTENT_SAFETY, + OPEN_AI: OPENAI, + ZENTROPI, +}; + +// Validate required sections at load time +for (const entry of Object.values(BUILT_IN_MANIFESTS)) { + assertModelCardHasRequiredSections(entry.modelCard); +} + +export type AvailableIntegration = Readonly<{ + name: string; + title: string; + docsUrl: string; + requiresConfig: boolean; + logoUrl?: string; + logoWithBackgroundUrl?: string; +}>; diff --git a/server/services/integrationRegistry/loadIntegrationsConfig.ts b/server/services/integrationRegistry/loadIntegrationsConfig.ts new file mode 100644 index 0000000..12e2adf --- /dev/null +++ b/server/services/integrationRegistry/loadIntegrationsConfig.ts @@ -0,0 +1,62 @@ +/** + * Loads and validates the adopters' integrations config file. + * Path: INTEGRATIONS_CONFIG_PATH env or cwd/integrations.config.json. + */ + +import { existsSync, readFileSync } from 'fs'; +import path from 'path'; + +import type { CoopIntegrationsConfig } from '@roostorg/types'; + +import { jsonParse } from '../../utils/encoding.js'; +import type { JsonOf } from '../../utils/encoding.js'; + +function getConfigPath(): string { + const envPath = process.env.INTEGRATIONS_CONFIG_PATH; + if (envPath != null && envPath !== '') { + return path.isAbsolute(envPath) ? envPath : path.join(process.cwd(), envPath); + } + const cwdPath = path.join(process.cwd(), 'integrations.config.json'); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- path from cwd/env, not user input + if (existsSync(cwdPath)) return cwdPath; + // When started from repo root (e.g. npm run start), cwd has no integrations.config.json; try server/ + const serverPath = path.join(process.cwd(), 'server', 'integrations.config.json'); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- path from cwd, not user input + if (existsSync(serverPath)) return serverPath; + return cwdPath; +} + +/** + * Returns the path to the integrations config file (for resolving relative package specs). + */ +export function getIntegrationsConfigPath(): string { + return getConfigPath(); +} + +/** + * Loads CoopIntegrationsConfig from the configured path. + * Returns { integrations: [] } if the file is missing (built-ins only). + */ +export function loadIntegrationsConfig(): CoopIntegrationsConfig { + const configPath = getConfigPath(); + try { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- path from getConfigPath (env/cwd), not user input + const raw = readFileSync(configPath, 'utf-8'); + const parsed = jsonParse(raw as JsonOf>); + if (typeof parsed !== 'object') { + return { integrations: [] }; + } + const o = parsed; + const integrations = Array.isArray(o.integrations) ? o.integrations : []; + const entries = integrations.filter( + (e): e is { package: string; enabled?: boolean; config?: Record } => + e != null && typeof e === 'object' && typeof (e as Record).package === 'string', + ); + return { integrations: entries }; + } catch (err: unknown) { + if (err != null && typeof err === 'object' && 'code' in err && err.code === 'ENOENT') { + return { integrations: [] }; + } + throw err; + } +} diff --git a/server/services/integrationRegistry/loadPlugins.ts b/server/services/integrationRegistry/loadPlugins.ts new file mode 100644 index 0000000..ff88859 --- /dev/null +++ b/server/services/integrationRegistry/loadPlugins.ts @@ -0,0 +1,192 @@ +/** + * Loads integration plugin packages from the config and maps their manifests + * to the server's IntegrationManifestEntry shape. Can also collect plugin + * entries for later signal loading (createSignals). + */ + +import fs from 'node:fs'; +import { createRequire } from 'module'; +import path from 'path'; + +import { + assertModelCardHasRequiredSections, + isCoopIntegrationPlugin, +} from '@roostorg/types'; +import type { CoopIntegrationsConfig } from '@roostorg/types'; + +import type { + IntegrationManifestEntry, + ModelCard, +} from './integrationManifests.js'; + +export type PluginManifestMap = Map; + +export type PluginEntry = Readonly<{ packageSpec: string; integrationId: string }>; + +export type LoadPluginsResult = Readonly<{ + manifests: PluginManifestMap; + pluginEntries: readonly PluginEntry[]; + /** integrationId -> absolute path to main logo (manifest.logoPath). */ + pluginLogoPaths: ReadonlyMap; + /** integrationId -> absolute path to "with background" logo (manifest.logoWithBackgroundPath). */ + pluginLogoWithBackgroundPaths: ReadonlyMap; +}>; + +const INTEGRATION_LOGOS_PATH_PREFIX = '/api/v1/integration-logos'; + +function findPackageRoot(startDir: string): string { + let dir = path.resolve(startDir); + const root = path.parse(dir).root; + while (dir !== root) { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- dir is from require.resolve (package entry), not user input + if (fs.existsSync(path.join(dir, 'package.json'))) { + return dir; + } + dir = path.dirname(dir); + } + return startDir; +} + +/** + * Loads each enabled integration package from config, validates as + * CoopIntegrationPlugin, and returns manifest map and list of plugin entries + * (packageSpec + integrationId) for later signal loading. + * Skips entries with enabled: false. Throws on invalid plugin or duplicate id. + * Package specs in config (e.g. "../coop-integration-example") are resolved + * relative to the directory containing the config file, so the server works + * whether started from server/ or repo root. + */ +/* eslint-disable complexity -- logo path resolution and fallbacks add branches; kept in one place for clarity. */ +export function loadPlugins( + config: CoopIntegrationsConfig, + configPath: string, +): LoadPluginsResult { + const require = createRequire(path.join(path.dirname(configPath), 'package.json')); + const map = new Map(); + const pluginEntries: PluginEntry[] = []; + const pluginLogoPaths = new Map(); + const pluginLogoWithBackgroundPaths = new Map(); + + for (const entry of config.integrations) { + if (entry.enabled === false) continue; + const packageSpec = entry.package; + let plugin: unknown; + try { + // eslint-disable-next-line security/detect-non-literal-require -- package spec from integrations config (deployment-controlled), not user input + plugin = require(packageSpec); + } catch (err: unknown) { + throw new Error( + `Failed to load integration package "${packageSpec}": ${err instanceof Error ? err.message : String(err)}`, + ); + } + const resolved: unknown = + (plugin as { default?: unknown }).default ?? plugin; + if (!isCoopIntegrationPlugin(resolved)) { + throw new Error( + `Integration package "${packageSpec}" does not export a valid CoopIntegrationPlugin (manifest with id, name, version, requiresConfig).`, + ); + } + const manifest = resolved.manifest; + const id = manifest.id; + if (map.has(id)) { + throw new Error( + `Duplicate integration id "${id}" from package "${packageSpec}".`, + ); + } + if (manifest.modelCard != null) { + assertModelCardHasRequiredSections(manifest.modelCard as ModelCard); + } + + let logoUrl = manifest.logoUrl; + let logoWithBackgroundUrl = manifest.logoWithBackgroundUrl; + const logoPath = (manifest as { logoPath?: string }).logoPath; + const logoWithBackgroundPath = (manifest as { logoWithBackgroundPath?: string }).logoWithBackgroundPath; + const entryPath = require.resolve(packageSpec); + const packageRoot = findPackageRoot(path.dirname(entryPath)); + const packageRootResolved = path.resolve(packageRoot); + + const resolveLogoPath = ( + relPath: string, + ): { fullPathResolved: string; found: boolean; pathToUse: string } => { + const normalized = path.normalize(relPath).replace(/^(\.\.(\/|\\))+/, ''); + const fullPath = path.join(packageRoot, normalized); + const fullPathResolved = path.resolve(fullPath); + if ( + !fullPathResolved.startsWith(packageRootResolved) || + path.relative(packageRootResolved, fullPathResolved).startsWith('..') + ) { + throw new Error( + `Integration "${id}" logo path must be inside the package: ${relPath}`, + ); + } + // eslint-disable-next-line security/detect-non-literal-fs-filename -- fullPathResolved is under package root from manifest path + let found = fs.existsSync(fullPathResolved); + let pathToUse = fullPathResolved; + if (!found) { + const altPath = path.join(packageRoot, relPath); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- altPath under package root from manifest + if (fs.existsSync(altPath)) { + pathToUse = path.resolve(altPath); + found = true; + } + } + return { fullPathResolved, found, pathToUse }; + }; + + // logoPath → plain logo (no background), served at /id as logoUrl — used on integrations page. + // logoWithBackgroundPath → logo with background, served at /id/with-background as logoWithBackgroundUrl — used in signal modals. + if (logoPath != null && logoPath.length > 0) { + const logoUrlPath = `${INTEGRATION_LOGOS_PATH_PREFIX}/${id}`; + const { fullPathResolved, found, pathToUse } = resolveLogoPath(logoPath); + if (found) { + pluginLogoPaths.set(id, pathToUse); + logoUrl = logoUrlPath; + if (logoWithBackgroundUrl == null && logoWithBackgroundPath == null) + logoWithBackgroundUrl = logoUrlPath; + } else { + logoUrl = logoUrlPath; + if (logoWithBackgroundUrl == null && logoWithBackgroundPath == null) + logoWithBackgroundUrl = logoUrlPath; + // eslint-disable-next-line no-console -- plugin load; SafeTracer may not be available yet. + console.warn( + `[integrations] Logo file not found for "${id}": tried ${fullPathResolved} (manifest.logoPath: ${logoPath})`, + ); + } + } + if (logoWithBackgroundPath != null && logoWithBackgroundPath.length > 0) { + const withBgUrlPath = `${INTEGRATION_LOGOS_PATH_PREFIX}/${id}/with-background`; + const { fullPathResolved, found, pathToUse } = resolveLogoPath(logoWithBackgroundPath); + if (found) { + pluginLogoWithBackgroundPaths.set(id, pathToUse); + logoWithBackgroundUrl = withBgUrlPath; + } else { + logoWithBackgroundUrl = withBgUrlPath; + // eslint-disable-next-line no-console -- plugin load; SafeTracer may not be available yet. + console.warn( + `[integrations] Logo-with-background file not found for "${id}": tried ${fullPathResolved} (manifest.logoWithBackgroundPath: ${logoWithBackgroundPath})`, + ); + } + } + + const serverEntry: IntegrationManifestEntry = { + title: manifest.name, + docsUrl: manifest.docsUrl ?? '', + requiresConfig: manifest.requiresConfig, + modelCard: manifest.modelCard as ModelCard, + modelCardLearnMoreUrl: (manifest as { modelCardLearnMoreUrl?: string }) + .modelCardLearnMoreUrl, + logoUrl, + logoWithBackgroundUrl, + }; + // Plugin manifest may omit modelCard; we require it for display. + if ((serverEntry as { modelCard?: ModelCard }).modelCard == null) { + throw new Error( + `Integration "${id}" (${packageSpec}) must provide a modelCard with at least "modelDetails" and "technicalIntegration" sections.`, + ); + } + map.set(id, serverEntry); + pluginEntries.push({ packageSpec, integrationId: id }); + } + + return { manifests: map, pluginEntries, pluginLogoPaths, pluginLogoWithBackgroundPaths }; +} diff --git a/server/services/moderationConfigService/types/rules.ts b/server/services/moderationConfigService/types/rules.ts index cd2cb36..cc4a609 100644 --- a/server/services/moderationConfigService/types/rules.ts +++ b/server/services/moderationConfigService/types/rules.ts @@ -46,12 +46,8 @@ export type ConditionSignalInfo = { args: SignalArgsByType['AGGREGATION']; } | { - type: Exclude; - // Our GQL input validation code assumes that, for all signals besides - // aggregation, the args must be undefined, so we want TS to give us a - // type error here if that ever becomes not true. Then, we can update the - // GQL validation code and any downstream code if the args for these other - // signals change. + // Exclude | string to support plugin signal types (e.g. RANDOM_SIGNAL_SELECTION) + type: Exclude | string; args?: Satisfies< SignalArgsByType[Exclude], undefined diff --git a/server/services/signalAuthService/dbTypes.ts b/server/services/signalAuthService/dbTypes.ts index 4ba8ec8..824fb42 100644 --- a/server/services/signalAuthService/dbTypes.ts +++ b/server/services/signalAuthService/dbTypes.ts @@ -1,6 +1,20 @@ import { type ColumnType } from 'kysely'; +/** JSONB config blob; shape defined per integration (see @roostorg/types StoredIntegrationConfigPayload). */ +export type IntegrationConfigRow = { + org_id: string; + integration_id: string; + config: ColumnType< + Record, + Record | string, + Record | string + >; + created_at: ColumnType; + updated_at: ColumnType; +}; + export type SignalAuthServicePg = { + 'signal_auth_service.integration_configs': IntegrationConfigRow; 'signal_auth_service.google_content_safety_configs': { org_id: string; api_key: string; diff --git a/server/services/signalAuthService/signalAuthService.ts b/server/services/signalAuthService/signalAuthService.ts index ae9615f..41ccf4b 100644 --- a/server/services/signalAuthService/signalAuthService.ts +++ b/server/services/signalAuthService/signalAuthService.ts @@ -89,8 +89,10 @@ export type CredentialImplementations = { */ class SignalAuthService { private implementations: CredentialImplementations; + private pg: Kysely; constructor(pg: Kysely) { + this.pg = pg; this.implementations = makeImplementations(pg); } @@ -115,6 +117,79 @@ class SignalAuthService { ): Promise { await this.implementations[integration].delete(orgId); } + + /** + * Get stored config by string integration id. For built-ins uses legacy tables; + * for plugin integrations uses the generic integration_configs table. + */ + async getByIntegrationId( + integrationId: string, + orgId: string, + ): Promise | undefined> { + if (integrationId === Integration.GOOGLE_CONTENT_SAFETY_API) { + const c = await this.get(Integration.GOOGLE_CONTENT_SAFETY_API, orgId); + return c != null ? { apiKey: c.apiKey } : undefined; + } + if (integrationId === Integration.OPEN_AI) { + const c = await this.get(Integration.OPEN_AI, orgId); + return c != null ? { apiKey: c.apiKey } : undefined; + } + if (integrationId === Integration.ZENTROPI) { + const c = await this.get(Integration.ZENTROPI, orgId); + return c != null ? { apiKey: c.apiKey, labelerVersions: c.labelerVersions } : undefined; + } + const row = await this.pg + .selectFrom('signal_auth_service.integration_configs') + .select(['config']) + .where('org_id', '=', orgId) + .where('integration_id', '=', integrationId) + .executeTakeFirst(); + if (row == null) return undefined; + const config = row.config; + // 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 + return config != null ? (config as Record) : undefined; + } + + /** + * Set stored config by string integration id. For built-ins uses legacy tables; + * for plugin integrations uses the generic integration_configs table. + */ + async setByIntegrationId( + integrationId: string, + orgId: string, + config: Record, + ): Promise> { + if (integrationId === Integration.GOOGLE_CONTENT_SAFETY_API) { + const apiKey = typeof config.apiKey === 'string' ? config.apiKey : ''; + await this.set(Integration.GOOGLE_CONTENT_SAFETY_API, orgId, { apiKey }); + return { apiKey }; + } + if (integrationId === Integration.OPEN_AI) { + const apiKey = typeof config.apiKey === 'string' ? config.apiKey : ''; + await this.set(Integration.OPEN_AI, orgId, { apiKey }); + return { apiKey }; + } + if (integrationId === Integration.ZENTROPI) { + const apiKey = typeof config.apiKey === 'string' ? config.apiKey : ''; + const labelerVersions = Array.isArray(config.labelerVersions) + ? (config.labelerVersions as ZentropiLabelerVersion[]) + : []; + await this.set(Integration.ZENTROPI, orgId, { apiKey, labelerVersions }); + return { apiKey, labelerVersions }; + } + await this.pg + .insertInto('signal_auth_service.integration_configs') + .values({ + org_id: orgId, + integration_id: integrationId, + config, + }) + .onConflict((oc) => + oc.columns(['org_id', 'integration_id']).doUpdateSet({ config }), + ) + .execute(); + return config; + } } export default inject(['KyselyPg'], SignalAuthService); diff --git a/server/services/signalsService/SignalsService.ts b/server/services/signalsService/SignalsService.ts index 963d06f..6155e59 100644 --- a/server/services/signalsService/SignalsService.ts +++ b/server/services/signalsService/SignalsService.ts @@ -8,13 +8,16 @@ import { jsonStringify } from '../../utils/encoding.js'; import { CoopError, ErrorType, makeNotFoundError } from '../../utils/errors.js'; import { __throw, assertUnreachable } from '../../utils/misc.js'; import { type CollapseCases } from '../../utils/typescript-types.js'; +import { getIntegrationRegistry } from '../integrationRegistry/index.js'; import { instantiateBuiltInSignals } from './helpers/instantiateBuiltInSignals.js'; +import { loadPluginSignals } from './helpers/loadPluginSignals.js'; import { makeCachedCredentialGetters } from './helpers/makeCachedCredentialsGetters.js'; import { makeCachedFetchers } from './helpers/makeCachedFetchers.js'; import { signalIsExternal, type SignalId, type SignalInputType, + type SignalOutputType, type SignalType, } from './index.js'; import type UnusedCustomSignal from './signals/CustomSignal.js'; @@ -55,7 +58,15 @@ const publicSignalProps = [ * itself. */ export type Signal = Simplify< - Pick, (typeof publicSignalProps)[number]> + Pick< + SignalBase< + SignalInputType, + SignalOutputType, + unknown, + SignalType | string + >, + (typeof publicSignalProps)[number] + > >; /** @@ -100,10 +111,17 @@ export type SignalTypesToRunOutputTypes = { [K in SignalType]: ReturnType; }; +/** All signals by type: built-in + plugin. Used for lookup and getSignalsForOrg. */ +type SignalsByType = Record< + string, + SignalBase +>; + export class SignalsService { public readonly close: () => Promise; private readonly builtInSignalsByType: BuiltInSignalsByType; + private readonly signalsByType: SignalsByType; constructor( private readonly tracer: Dependencies['Tracer'], @@ -134,12 +152,24 @@ export class SignalsService { this.hmaService, ); + const pluginEntries = getIntegrationRegistry().getPluginEntries(); + const pluginSignals = loadPluginSignals(pluginEntries, signalAuthService); + const builtInIds = new Set(Object.keys(this.builtInSignalsByType)); + const collision = Object.keys(pluginSignals).find((id) => builtInIds.has(id)); + if (collision != null) { + throw new Error( + `Plugin signal type "${collision}" collides with a built-in signal; use a different signalTypeId.`, + ); + } + this.signalsByType = { + ...this.builtInSignalsByType, + ...pluginSignals, + }; + this.close = async function () { + await cachedCredentialGetters.close(); await Promise.all( - [ - ...Object.values(cachedCredentialGetters), - ...Object.values(cachedFetchers), - ].map(async (it) => it.close()), + Object.values(cachedFetchers).map(async (it) => it.close()), ); }; } @@ -161,36 +191,35 @@ export class SignalsService { }): Promise { const { orgId, externalOnly = true } = opts; - const builtInSignals = Object.values(this.builtInSignalsByType).filter( - (signal) => isSignalEnabledForOrg(signal.id, orgId), + const allSignals = Object.values(this.signalsByType).filter((signal) => + isSignalEnabledForOrg(signal.id, orgId), ); - const finalBuiltInSignals = externalOnly - ? builtInSignals.filter((it) => signalIsExternal(it.id)) - : builtInSignals; + const finalSignals = externalOnly + ? allSignals.filter((it) => signalIsExternal(it.id)) + : allSignals; - return finalBuiltInSignals.map((it) => - this.#signalInstanceToPublicSignal(it), - ); + return finalSignals.map((it) => this.#signalInstanceToPublicSignal(it)); } async #getSignalInstance( ref: SignalReference, - ): Promise | undefined> { + ): Promise< + | SignalBase< + SignalInputType, + SignalOutputType, + unknown, + SignalType | string + > + | undefined + > { const { signalId } = ref; - // In this switch, we don't have assertUnreachable in the default case, but - // TS will give an error if the the potential values that are left in - // `signalId.type` aren't all usable to index into builtinSignalsByType - // eslint-disable-next-line switch-statement/require-appropriate-default-case - switch (signalId.type) { - case 'CUSTOM': - throw new Error('not implemented'); - default: - // For some reason, TS won't narrow the type based on the previous - // case expressions, so we explicitly narrow it here. - return this.builtInSignalsByType[signalId.type as Exclude]; + if (signalId.type === 'CUSTOM') { + throw new Error('not implemented'); } + + return this.signalsByType[signalId.type]; } public async getSignal(ref: SignalReference) { @@ -302,7 +331,11 @@ export class SignalsService { >; } - #signalInstanceToPublicSignal(it: ReadonlyDeep>) { + #signalInstanceToPublicSignal( + it: ReadonlyDeep< + SignalBase + >, + ) { // This used to be implemented as simply `safePick(it, publicSignalProps)`, // but we found that this is such a hot path that lodash was adding very // noticeable overhead -- `_.pick` calls all kinds of internal lodash diff --git a/server/services/signalsService/helpers/loadPluginSignals.ts b/server/services/signalsService/helpers/loadPluginSignals.ts new file mode 100644 index 0000000..6a8d838 --- /dev/null +++ b/server/services/signalsService/helpers/loadPluginSignals.ts @@ -0,0 +1,91 @@ +/** + * Loads signal implementations from integration plugins and wraps them in + * PluginSignalAdapter so they can be registered and used in rules. + */ + +import { createRequire } from 'module'; +import path from 'path'; + +import { isCoopIntegrationPlugin } from '@roostorg/types'; +import PluginSignalAdapter, { + type PluginSignalDescriptor, +} from '../signals/PluginSignalAdapter.js'; +import type { + SignalBase, + SignalInputType, +} from '../signals/SignalBase.js'; +import type { PluginEntry } from '../../integrationRegistry/loadPlugins.js'; +import type { SignalAuthService } from '../../signalAuthService/index.js'; +import type { SignalOutputType } from '../types/SignalOutputType.js'; + +export type PluginSignalsByType = Record< + string, + SignalBase +>; + +/** + * For each plugin entry, requires the package, calls createSignals(context) if + * present, wraps each returned descriptor in PluginSignalAdapter, and returns + * a map of signalTypeId -> adapter. Uses getByIntegrationId for credential lookup. + */ +export function loadPluginSignals( + pluginEntries: readonly PluginEntry[], + signalAuthService: SignalAuthService, +): PluginSignalsByType { + const require = createRequire(path.join(process.cwd(), 'package.json')); + const byType: PluginSignalsByType = {}; + + for (const { packageSpec, integrationId } of pluginEntries) { + let plugin: unknown; + try { + // eslint-disable-next-line security/detect-non-literal-require -- package spec from integrations config (deployment-controlled), not user input + plugin = require(packageSpec); + } catch (err: unknown) { + throw new Error( + `Failed to load integration package "${packageSpec}" for signals: ${err instanceof Error ? err.message : String(err)}`, + ); + } + const resolved: unknown = + (plugin as { default?: unknown }).default ?? plugin; + if (!isCoopIntegrationPlugin(resolved)) continue; + const createSignals = (resolved as { createSignals?: (ctx: unknown) => unknown[] }).createSignals; + if (typeof createSignals !== 'function') continue; + + const getCredential = async (orgId: string) => + signalAuthService.getByIntegrationId(integrationId, orgId); + const context = { integrationId, getCredential }; + let signals: unknown[]; + try { + const raw = createSignals(context); + signals = Array.isArray(raw) ? raw : []; + } catch (err: unknown) { + throw new Error( + `Plugin "${packageSpec}" createSignals failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + for (const item of signals) { + if ( + item == null || + typeof item !== 'object' || + !('signalTypeId' in item) || + !('signal' in item) + ) + continue; + const { signalTypeId, signal: descriptor } = item as { + signalTypeId: string; + signal: unknown; + }; + if (typeof signalTypeId !== 'string' || descriptor == null) continue; + if (signalTypeId in byType) { + throw new Error( + `Duplicate plugin signal type "${signalTypeId}" from package "${packageSpec}".`, + ); + } + byType[signalTypeId] = new PluginSignalAdapter( + descriptor as PluginSignalDescriptor, + ); + } + } + + return byType; +} diff --git a/server/services/signalsService/helpers/makeCachedCredentialsGetters.ts b/server/services/signalsService/helpers/makeCachedCredentialsGetters.ts index 0b18e96..b76b3e6 100644 --- a/server/services/signalsService/helpers/makeCachedCredentialsGetters.ts +++ b/server/services/signalsService/helpers/makeCachedCredentialsGetters.ts @@ -2,12 +2,18 @@ import { type Dependencies } from '../../../iocContainer/index.js'; import { cached } from '../../../utils/caching.js'; import { type ConfigurableIntegration } from '../../signalAuthService/signalAuthService.js'; +type CredentialCache = { + (orgId: string): Promise | undefined>; + close(): Promise; +}; + export type CredentialGetters = ReturnType; /** * Returns a set of functions that can be used for looking up an org's stored * API keys for a given third-party service, which is needed when running - * signals that connect to that service. + * signals that connect to that service. Also provides getForIntegrationId for + * plugin integrations (any string id). Call close() to dispose all caches. */ export function makeCachedCredentialGetters( signalAuthService: Dependencies['SignalAuthService'], @@ -21,11 +27,40 @@ export function makeCachedCredentialGetters( directives: { freshUntilAge: 600 }, }); - return { + // Create built-in caches once so close() disposes the same instances in use. + const builtInCaches = { GOOGLE_CONTENT_SAFETY_API: getApiCredentialForIntegration( 'GOOGLE_CONTENT_SAFETY_API', ), OPEN_AI: getApiCredentialForIntegration('OPEN_AI'), ZENTROPI: getApiCredentialForIntegration('ZENTROPI'), }; + + const cacheByIntegrationId = new Map(); + + function getForIntegrationId(integrationId: string): CredentialCache { + let c = cacheByIntegrationId.get(integrationId); + if (c == null) { + c = cached({ + producer: async (orgId: string) => + signalAuthService.getByIntegrationId(integrationId, orgId), + directives: { freshUntilAge: 600 }, + }); + cacheByIntegrationId.set(integrationId, c); + } + return c; + } + + async function close(): Promise { + await Promise.all([ + ...Object.values(builtInCaches).map(async (c) => c.close()), + ...Array.from(cacheByIntegrationId.values(), async (c) => c.close()), + ]); + } + + return { + ...builtInCaches, + getForIntegrationId, + close, + }; } diff --git a/server/services/signalsService/signals/PluginSignalAdapter.ts b/server/services/signalsService/signals/PluginSignalAdapter.ts new file mode 100644 index 0000000..c5d4e33 --- /dev/null +++ b/server/services/signalsService/signals/PluginSignalAdapter.ts @@ -0,0 +1,141 @@ +/** + * Adapts a plugin's PluginSignalDescriptor to the server's SignalBase so plugin + * signals can be registered and used in routing/enforcement rules. + */ + +import type { SignalSubcategory } from '@roostorg/types'; +import { type ReadonlyDeep } from 'type-fest'; + +import { type Language } from '../../../utils/language.js'; +import SignalBase, { + type SignalDisabledInfo, + type SignalErrorResult, + type SignalInput, + type SignalInputType, + type SignalResult, +} from './SignalBase.js'; +import { type SignalOutputType } from '../types/SignalOutputType.js'; +import { type SignalPricingStructure } from '../types/SignalPricingStructure.js'; + +/** Minimal descriptor shape from a plugin; matches @roostorg/types PluginSignalDescriptor. */ +export type PluginSignalDescriptor = Readonly<{ + id: { type: string }; + displayName: string; + description: string; + docsUrl: string | null; + recommendedThresholds: Readonly<{ + highPrecisionThreshold: string | number; + highRecallThreshold: string | number; + }> | null; + supportedLanguages: readonly string[] | 'ALL'; + pricingStructure: { type: 'FREE' | 'SUBSCRIPTION' }; + eligibleInputs: readonly string[]; + outputType: Readonly<{ scalarType: string }>; + getCost: () => number; + run: (input: unknown) => Promise; + getDisabledInfo: (orgId: string) => Promise< + | { disabled: false; disabledMessage?: string } + | { disabled: true; disabledMessage: string } + >; + needsMatchingValues: boolean; + eligibleSubcategories: ReadonlyArray<{ + id: string; + label: string; + description?: string; + childrenIds: readonly string[]; + }>; + needsActionPenalties: boolean; + integration: string; + allowedInAutomatedRules: boolean; +}>; + +/** + * Wraps a plugin-provided descriptor so it satisfies SignalBase and can be + * registered in the signals map and used by the rule engine. + */ +export default class PluginSignalAdapter extends SignalBase< + SignalInputType, + SignalOutputType, + unknown, + string +> { + constructor( + private readonly descriptor: ReadonlyDeep, + ) { + super(); + } + + override get id() { + return this.descriptor.id; + } + + override get displayName() { + return this.descriptor.displayName; + } + + override get description() { + return this.descriptor.description; + } + + override get docsUrl() { + return this.descriptor.docsUrl; + } + + override get recommendedThresholds() { + return this.descriptor.recommendedThresholds; + } + + override get supportedLanguages() { + const L = this.descriptor.supportedLanguages; + return (L === 'ALL' ? 'ALL' : L) as readonly Language[] | 'ALL'; + } + + override get pricingStructure() { + return this.descriptor.pricingStructure as unknown as SignalPricingStructure; + } + + override get eligibleInputs() { + return this.descriptor.eligibleInputs as readonly SignalInputType[]; + } + + override get outputType() { + return this.descriptor.outputType as SignalOutputType; + } + + override getCost() { + return this.descriptor.getCost(); + } + + override async run( + input: SignalInput, + ): Promise | SignalErrorResult> { + const result = await this.descriptor.run(input); + return result as SignalResult | SignalErrorResult; + } + + override async getDisabledInfo(orgId: string): Promise { + return this.descriptor.getDisabledInfo(orgId) as Promise; + } + + override get needsMatchingValues() { + return this.descriptor.needsMatchingValues; + } + + override get eligibleSubcategories() { + return this.descriptor.eligibleSubcategories as unknown as ReadonlyDeep< + SignalSubcategory[] + >; + } + + override get needsActionPenalties() { + return this.descriptor.needsActionPenalties; + } + + override get integration() { + return this.descriptor.integration; + } + + override get allowedInAutomatedRules() { + return this.descriptor.allowedInAutomatedRules; + } +} diff --git a/server/services/signalsService/signals/SignalBase.ts b/server/services/signalsService/signals/SignalBase.ts index 93b5323..8be6176 100644 --- a/server/services/signalsService/signals/SignalBase.ts +++ b/server/services/signalsService/signals/SignalBase.ts @@ -65,7 +65,7 @@ export type SignalInput< NeedsMatchingValues extends boolean = boolean, NeedsActionPenalties extends boolean = boolean, MatchingValue = T extends ScalarType ? ScalarTypeRuntimeType : unknown, - Type extends SignalType = SignalType, + Type extends SignalType | string = SignalType, > = { value: T extends 'FULL_ITEM' ? TaggedItemData @@ -88,9 +88,13 @@ export type SignalInput< // TODO: figure out a better, generalized way to capture signals' required params. contextId?: string; contentType?: string; - args?: ReadonlyDeep; + args?: ReadonlyDeep< + Type extends SignalType ? SignalArgsByType[Type] : unknown + >; - runtimeArgs?: ReadonlyDeep; + runtimeArgs?: ReadonlyDeep< + Type extends SignalType ? RuntimeSignalArgsByType[Type] : unknown + >; }; // The result of running a signal can be: @@ -140,7 +144,7 @@ export default abstract class SignalBase< MatchingValue = Input extends ScalarType ? ScalarTypeRuntimeType : unknown, - Type extends SignalType = SignalType, + Type extends SignalType | string = SignalType, > { /** * See {@link SignalId}. @@ -272,7 +276,8 @@ export default abstract class SignalBase< abstract get needsActionPenalties(): boolean; - abstract get integration(): Integration | null; + /** Built-in integration enum value, or integration id string for plugin signals. */ + abstract get integration(): Integration | string | null; /** * Indicates whether this signal can be used in automated rules with actions. diff --git a/server/services/signalsService/types/SignalId.ts b/server/services/signalsService/types/SignalId.ts index f1e28fb..7c70237 100644 --- a/server/services/signalsService/types/SignalId.ts +++ b/server/services/signalsService/types/SignalId.ts @@ -30,9 +30,8 @@ export type SignalId = InternalSignalId | ExternalSignalId; export type InternalSignalId = { type: InternalSignalType }; export type ExternalSignalId = | { type: typeof SignalType.CUSTOM; id: NonEmptyString } - | { - type: Exclude; - }; + | { type: Exclude } + | { type: string }; // plugin signal type ids (not in SignalType enum) export const InternalSignalIdArbitrary = fc.record({ type: InternalSignalTypeArbitrary, @@ -79,7 +78,7 @@ export function isSignalId(it: unknown): it is SignalId { typeof it === 'object' && it !== null && 'type' in it && - Object.hasOwn(SignalType, it.type as string) && + typeof (it as { type: unknown }).type === 'string' && (it.type === SignalType.CUSTOM ? 'id' in it && isNonEmptyString(it.id) : true) diff --git a/server/services/signalsService/types/SignalType.ts b/server/services/signalsService/types/SignalType.ts index 8abd311..b0a495f 100644 --- a/server/services/signalsService/types/SignalType.ts +++ b/server/services/signalsService/types/SignalType.ts @@ -1,7 +1,6 @@ import { makeEnumLike } from '@roostorg/types'; import { enumToArbitrary } from '../../../test/propertyTestingHelpers.js'; -import { assertUnreachable } from '../../../utils/misc.js'; import { Integration } from './Integration.js'; // Internal signal types are always built-in signals. @@ -85,8 +84,9 @@ export const UserCreatedExternalSignalTypeArbitrary = enumToArbitrary( ); export const ExternalSignalTypeArbitrary = enumToArbitrary(ExternalSignalType); +/** Accepts SignalType or plugin signal type string (e.g. RANDOM_SIGNAL_SELECTION). */ // eslint-disable-next-line complexity -export function integrationForSignalType(type: SignalType) { +export function integrationForSignalType(type: SignalType | string) { switch (type) { case 'GOOGLE_CONTENT_SAFETY_API_IMAGE': return Integration.GOOGLE_CONTENT_SAFETY_API; @@ -120,6 +120,7 @@ export function integrationForSignalType(type: SignalType) { case 'BENIGN_MODEL': return null; default: - assertUnreachable(type); + // Plugin signal types (e.g. RANDOM_SIGNAL_SELECTION): no built-in integration + return null; } } diff --git a/types/integration.ts b/types/integration.ts index 9df354f..b0689f9 100644 --- a/types/integration.ts +++ b/types/integration.ts @@ -158,6 +158,8 @@ export type IntegrationManifest = Readonly<{ * If you provide logoPath and logoWithBackgroundPath, the server will serve the files at * GET /api/v1/integration-logos/:integrationId and GET /api/v1/integration-logos/:integrationId/with-background * and set logoUrl and logoWithBackgroundUrl accordingly. + * Usage: logoUrl/logoPath = plain logo (no background), used on the integrations page; + * logoWithBackgroundUrl/logoWithBackgroundPath = logo with background, used in signal modals. * If you provide logoUrl and logoWithBackgroundUrl, the server will use those URLs directly. * Prefered size: ~180x180px for logoUrl and ~120x120px for logoWithBackgroundUrl. * Prefer a square or horizontal logo that scales well.