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

Configure Feed

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

[HMA][Exchanges] Configure HMA exchanges directly from coop (#115)

* remove sample exchanges

* [HMA][Exchanges] Configure HMA exchanges directly from coop

* tests

* capitalize first value of the keys

* fix tests

* code review changes

* fix eslint

* fix eslint

* fix for real now

authored by

Juan Mrad and committed by
GitHub
9452c326 ea11c232

+2164 -66
+341
client/src/graphql/generated.ts
··· 36 36 * fields. 37 37 */ 38 38 DateTime: Date | string; 39 + /** Represents any JSON value (object, array, string, number, boolean, null). */ 40 + JSON: JsonValue; 39 41 /** Represents an arbitrary json object. */ 40 42 JSONObject: JsonObject; 41 43 /** Represents a string that must be non-empty. */ ··· 682 684 export type GQLCreateHashBankInput = { 683 685 readonly description?: InputMaybe<Scalars['String']>; 684 686 readonly enabled_ratio: Scalars['Float']; 687 + readonly exchange?: InputMaybe<GQLExchangeConfigInput>; 685 688 readonly name: Scalars['String']; 686 689 }; 687 690 ··· 1077 1080 readonly type: ReadonlyArray<Scalars['String']>; 1078 1081 }; 1079 1082 1083 + export type GQLExchangeApiInfo = { 1084 + readonly __typename: 'ExchangeApiInfo'; 1085 + readonly has_auth: Scalars['Boolean']; 1086 + readonly name: Scalars['String']; 1087 + readonly supports_auth: Scalars['Boolean']; 1088 + }; 1089 + 1090 + export type GQLExchangeApiSchema = { 1091 + readonly __typename: 'ExchangeApiSchema'; 1092 + readonly config_schema: GQLExchangeSchemaSection; 1093 + readonly credentials_schema?: Maybe<GQLExchangeSchemaSection>; 1094 + }; 1095 + 1096 + export type GQLExchangeConfigInput = { 1097 + readonly api_name: Scalars['String']; 1098 + readonly config_json: Scalars['String']; 1099 + readonly credentials_json?: InputMaybe<Scalars['String']>; 1100 + }; 1101 + 1102 + export type GQLExchangeFieldDescriptor = { 1103 + readonly __typename: 'ExchangeFieldDescriptor'; 1104 + readonly choices?: Maybe<ReadonlyArray<Scalars['String']>>; 1105 + readonly default?: Maybe<Scalars['JSON']>; 1106 + readonly help?: Maybe<Scalars['String']>; 1107 + readonly name: Scalars['String']; 1108 + readonly required: Scalars['Boolean']; 1109 + readonly type: Scalars['String']; 1110 + }; 1111 + 1112 + export type GQLExchangeInfo = { 1113 + readonly __typename: 'ExchangeInfo'; 1114 + readonly api: Scalars['String']; 1115 + readonly enabled: Scalars['Boolean']; 1116 + readonly error?: Maybe<Scalars['String']>; 1117 + readonly fetched_items?: Maybe<Scalars['Int']>; 1118 + readonly has_auth: Scalars['Boolean']; 1119 + readonly is_fetching?: Maybe<Scalars['Boolean']>; 1120 + readonly last_fetch_succeeded?: Maybe<Scalars['Boolean']>; 1121 + readonly last_fetch_time?: Maybe<Scalars['String']>; 1122 + readonly up_to_date?: Maybe<Scalars['Boolean']>; 1123 + }; 1124 + 1125 + export type GQLExchangeSchemaSection = { 1126 + readonly __typename: 'ExchangeSchemaSection'; 1127 + readonly fields: ReadonlyArray<GQLExchangeFieldDescriptor>; 1128 + }; 1129 + 1080 1130 export type GQLExecuteActionResponse = { 1081 1131 readonly __typename: 'ExecuteActionResponse'; 1082 1132 readonly actionId: Scalars['String']; ··· 1230 1280 readonly __typename: 'HashBank'; 1231 1281 readonly description?: Maybe<Scalars['String']>; 1232 1282 readonly enabled_ratio: Scalars['Float']; 1283 + readonly exchange?: Maybe<GQLExchangeInfo>; 1233 1284 readonly hma_name: Scalars['String']; 1234 1285 readonly id: Scalars['ID']; 1235 1286 readonly name: Scalars['String']; ··· 2198 2249 export type GQLMutateHashBankSuccessResponse = { 2199 2250 readonly __typename: 'MutateHashBankSuccessResponse'; 2200 2251 readonly data: GQLHashBank; 2252 + readonly warning?: Maybe<Scalars['String']>; 2201 2253 }; 2202 2254 2203 2255 export type GQLMutateLocationBankResponse = ··· 2321 2373 readonly updateAppealSettings: GQLAppealSettings; 2322 2374 readonly updateContentItemType: GQLMutateContentItemTypeResponse; 2323 2375 readonly updateContentRule: GQLUpdateContentRuleResponse; 2376 + readonly updateExchangeCredentials: Scalars['Boolean']; 2324 2377 readonly updateHashBank: GQLMutateHashBankResponse; 2325 2378 readonly updateLocationBank: GQLMutateLocationBankResponse; 2326 2379 readonly updateManualReviewQueue: GQLUpdateManualReviewQueueQueueResponse; ··· 2597 2650 2598 2651 export type GQLMutationUpdateContentRuleArgs = { 2599 2652 input: GQLUpdateContentRuleInput; 2653 + }; 2654 + 2655 + export type GQLMutationUpdateExchangeCredentialsArgs = { 2656 + apiName: Scalars['String']; 2657 + credentialsJson: Scalars['String']; 2600 2658 }; 2601 2659 2602 2660 export type GQLMutationUpdateHashBankArgs = { ··· 3093 3151 readonly apiKey: Scalars['String']; 3094 3152 readonly appealSettings?: Maybe<GQLAppealSettings>; 3095 3153 readonly availableIntegrations: ReadonlyArray<GQLIntegrationMetadata>; 3154 + readonly exchangeApiSchema?: Maybe<GQLExchangeApiSchema>; 3155 + readonly exchangeApis: ReadonlyArray<GQLExchangeApiInfo>; 3096 3156 readonly getCommentsForJob: ReadonlyArray<GQLManualReviewJobComment>; 3097 3157 readonly getDecidedJob?: Maybe<GQLManualReviewJob>; 3098 3158 readonly getDecidedJobFromJobId?: Maybe<GQLManualReviewJobWithDecisions>; ··· 3155 3215 3156 3216 export type GQLQueryActionStatisticsArgs = { 3157 3217 input: GQLActionStatisticsInput; 3218 + }; 3219 + 3220 + export type GQLQueryExchangeApiSchemaArgs = { 3221 + apiName: Scalars['String']; 3158 3222 }; 3159 3223 3160 3224 export type GQLQueryGetCommentsForJobArgs = { ··· 4907 4971 readonly hma_name: string; 4908 4972 readonly enabled_ratio: number; 4909 4973 readonly org_id: string; 4974 + readonly exchange?: { 4975 + readonly __typename: 'ExchangeInfo'; 4976 + readonly api: string; 4977 + readonly enabled: boolean; 4978 + readonly has_auth: boolean; 4979 + readonly error?: string | null; 4980 + readonly last_fetch_succeeded?: boolean | null; 4981 + readonly last_fetch_time?: string | null; 4982 + readonly up_to_date?: boolean | null; 4983 + readonly fetched_items?: number | null; 4984 + readonly is_fetching?: boolean | null; 4985 + } | null; 4986 + } | null; 4987 + }; 4988 + 4989 + export type GQLExchangeApisQueryVariables = Exact<{ [key: string]: never }>; 4990 + 4991 + export type GQLExchangeApisQuery = { 4992 + readonly __typename: 'Query'; 4993 + readonly exchangeApis: ReadonlyArray<{ 4994 + readonly __typename: 'ExchangeApiInfo'; 4995 + readonly name: string; 4996 + readonly supports_auth: boolean; 4997 + readonly has_auth: boolean; 4998 + }>; 4999 + }; 5000 + 5001 + export type GQLExchangeApiSchemaQueryVariables = Exact<{ 5002 + apiName: Scalars['String']; 5003 + }>; 5004 + 5005 + export type GQLExchangeApiSchemaQuery = { 5006 + readonly __typename: 'Query'; 5007 + readonly exchangeApiSchema?: { 5008 + readonly __typename: 'ExchangeApiSchema'; 5009 + readonly config_schema: { 5010 + readonly __typename: 'ExchangeSchemaSection'; 5011 + readonly fields: ReadonlyArray<{ 5012 + readonly __typename: 'ExchangeFieldDescriptor'; 5013 + readonly name: string; 5014 + readonly type: string; 5015 + readonly required: boolean; 5016 + readonly default?: JsonValue | null; 5017 + readonly help?: string | null; 5018 + readonly choices?: ReadonlyArray<string> | null; 5019 + }>; 5020 + }; 5021 + readonly credentials_schema?: { 5022 + readonly __typename: 'ExchangeSchemaSection'; 5023 + readonly fields: ReadonlyArray<{ 5024 + readonly __typename: 'ExchangeFieldDescriptor'; 5025 + readonly name: string; 5026 + readonly type: string; 5027 + readonly required: boolean; 5028 + readonly default?: JsonValue | null; 5029 + readonly help?: string | null; 5030 + readonly choices?: ReadonlyArray<string> | null; 5031 + }>; 5032 + } | null; 4910 5033 } | null; 4911 5034 }; 4912 5035 ··· 4928 5051 } 4929 5052 | { 4930 5053 readonly __typename: 'MutateHashBankSuccessResponse'; 5054 + readonly warning?: string | null; 4931 5055 readonly data: { 4932 5056 readonly __typename: 'HashBank'; 4933 5057 readonly id: string; ··· 4977 5101 export type GQLDeleteHashBankMutation = { 4978 5102 readonly __typename: 'Mutation'; 4979 5103 readonly deleteHashBank: boolean; 5104 + }; 5105 + 5106 + export type GQLUpdateExchangeCredentialsMutationVariables = Exact<{ 5107 + apiName: Scalars['String']; 5108 + credentialsJson: Scalars['String']; 5109 + }>; 5110 + 5111 + export type GQLUpdateExchangeCredentialsMutation = { 5112 + readonly __typename: 'Mutation'; 5113 + readonly updateExchangeCredentials: boolean; 4980 5114 }; 4981 5115 4982 5116 export type GQLUserAndOrgQueryVariables = Exact<{ [key: string]: never }>; ··· 25191 25325 hma_name 25192 25326 enabled_ratio 25193 25327 org_id 25328 + exchange { 25329 + api 25330 + enabled 25331 + has_auth 25332 + error 25333 + last_fetch_succeeded 25334 + last_fetch_time 25335 + up_to_date 25336 + fetched_items 25337 + is_fetching 25338 + } 25194 25339 } 25195 25340 } 25196 25341 `; ··· 25245 25390 GQLHashBankByIdQuery, 25246 25391 GQLHashBankByIdQueryVariables 25247 25392 >; 25393 + export const GQLExchangeApisDocument = gql` 25394 + query ExchangeApis { 25395 + exchangeApis { 25396 + name 25397 + supports_auth 25398 + has_auth 25399 + } 25400 + } 25401 + `; 25402 + 25403 + /** 25404 + * __useGQLExchangeApisQuery__ 25405 + * 25406 + * To run a query within a React component, call `useGQLExchangeApisQuery` and pass it any options that fit your needs. 25407 + * When your component renders, `useGQLExchangeApisQuery` returns an object from Apollo Client that contains loading, error, and data properties 25408 + * you can use to render your UI. 25409 + * 25410 + * @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; 25411 + * 25412 + * @example 25413 + * const { data, loading, error } = useGQLExchangeApisQuery({ 25414 + * variables: { 25415 + * }, 25416 + * }); 25417 + */ 25418 + export function useGQLExchangeApisQuery( 25419 + baseOptions?: Apollo.QueryHookOptions< 25420 + GQLExchangeApisQuery, 25421 + GQLExchangeApisQueryVariables 25422 + >, 25423 + ) { 25424 + const options = { ...defaultOptions, ...baseOptions }; 25425 + return Apollo.useQuery<GQLExchangeApisQuery, GQLExchangeApisQueryVariables>( 25426 + GQLExchangeApisDocument, 25427 + options, 25428 + ); 25429 + } 25430 + export function useGQLExchangeApisLazyQuery( 25431 + baseOptions?: Apollo.LazyQueryHookOptions< 25432 + GQLExchangeApisQuery, 25433 + GQLExchangeApisQueryVariables 25434 + >, 25435 + ) { 25436 + const options = { ...defaultOptions, ...baseOptions }; 25437 + return Apollo.useLazyQuery< 25438 + GQLExchangeApisQuery, 25439 + GQLExchangeApisQueryVariables 25440 + >(GQLExchangeApisDocument, options); 25441 + } 25442 + export type GQLExchangeApisQueryHookResult = ReturnType< 25443 + typeof useGQLExchangeApisQuery 25444 + >; 25445 + export type GQLExchangeApisLazyQueryHookResult = ReturnType< 25446 + typeof useGQLExchangeApisLazyQuery 25447 + >; 25448 + export type GQLExchangeApisQueryResult = Apollo.QueryResult< 25449 + GQLExchangeApisQuery, 25450 + GQLExchangeApisQueryVariables 25451 + >; 25452 + export const GQLExchangeApiSchemaDocument = gql` 25453 + query ExchangeApiSchema($apiName: String!) { 25454 + exchangeApiSchema(apiName: $apiName) { 25455 + config_schema { 25456 + fields { 25457 + name 25458 + type 25459 + required 25460 + default 25461 + help 25462 + choices 25463 + } 25464 + } 25465 + credentials_schema { 25466 + fields { 25467 + name 25468 + type 25469 + required 25470 + default 25471 + help 25472 + choices 25473 + } 25474 + } 25475 + } 25476 + } 25477 + `; 25478 + 25479 + /** 25480 + * __useGQLExchangeApiSchemaQuery__ 25481 + * 25482 + * To run a query within a React component, call `useGQLExchangeApiSchemaQuery` and pass it any options that fit your needs. 25483 + * When your component renders, `useGQLExchangeApiSchemaQuery` returns an object from Apollo Client that contains loading, error, and data properties 25484 + * you can use to render your UI. 25485 + * 25486 + * @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; 25487 + * 25488 + * @example 25489 + * const { data, loading, error } = useGQLExchangeApiSchemaQuery({ 25490 + * variables: { 25491 + * apiName: // value for 'apiName' 25492 + * }, 25493 + * }); 25494 + */ 25495 + export function useGQLExchangeApiSchemaQuery( 25496 + baseOptions: Apollo.QueryHookOptions< 25497 + GQLExchangeApiSchemaQuery, 25498 + GQLExchangeApiSchemaQueryVariables 25499 + >, 25500 + ) { 25501 + const options = { ...defaultOptions, ...baseOptions }; 25502 + return Apollo.useQuery< 25503 + GQLExchangeApiSchemaQuery, 25504 + GQLExchangeApiSchemaQueryVariables 25505 + >(GQLExchangeApiSchemaDocument, options); 25506 + } 25507 + export function useGQLExchangeApiSchemaLazyQuery( 25508 + baseOptions?: Apollo.LazyQueryHookOptions< 25509 + GQLExchangeApiSchemaQuery, 25510 + GQLExchangeApiSchemaQueryVariables 25511 + >, 25512 + ) { 25513 + const options = { ...defaultOptions, ...baseOptions }; 25514 + return Apollo.useLazyQuery< 25515 + GQLExchangeApiSchemaQuery, 25516 + GQLExchangeApiSchemaQueryVariables 25517 + >(GQLExchangeApiSchemaDocument, options); 25518 + } 25519 + export type GQLExchangeApiSchemaQueryHookResult = ReturnType< 25520 + typeof useGQLExchangeApiSchemaQuery 25521 + >; 25522 + export type GQLExchangeApiSchemaLazyQueryHookResult = ReturnType< 25523 + typeof useGQLExchangeApiSchemaLazyQuery 25524 + >; 25525 + export type GQLExchangeApiSchemaQueryResult = Apollo.QueryResult< 25526 + GQLExchangeApiSchemaQuery, 25527 + GQLExchangeApiSchemaQueryVariables 25528 + >; 25248 25529 export const GQLCreateHashBankDocument = gql` 25249 25530 mutation CreateHashBank($input: CreateHashBankInput!) { 25250 25531 createHashBank(input: $input) { ··· 25257 25538 enabled_ratio 25258 25539 org_id 25259 25540 } 25541 + warning 25260 25542 } 25261 25543 ... on MatchingBankNameExistsError { 25262 25544 title ··· 25427 25709 GQLDeleteHashBankMutation, 25428 25710 GQLDeleteHashBankMutationVariables 25429 25711 >; 25712 + export const GQLUpdateExchangeCredentialsDocument = gql` 25713 + mutation UpdateExchangeCredentials( 25714 + $apiName: String! 25715 + $credentialsJson: String! 25716 + ) { 25717 + updateExchangeCredentials( 25718 + apiName: $apiName 25719 + credentialsJson: $credentialsJson 25720 + ) 25721 + } 25722 + `; 25723 + export type GQLUpdateExchangeCredentialsMutationFn = Apollo.MutationFunction< 25724 + GQLUpdateExchangeCredentialsMutation, 25725 + GQLUpdateExchangeCredentialsMutationVariables 25726 + >; 25727 + 25728 + /** 25729 + * __useGQLUpdateExchangeCredentialsMutation__ 25730 + * 25731 + * To run a mutation, you first call `useGQLUpdateExchangeCredentialsMutation` within a React component and pass it any options that fit your needs. 25732 + * When your component renders, `useGQLUpdateExchangeCredentialsMutation` returns a tuple that includes: 25733 + * - A mutate function that you can call at any time to execute the mutation 25734 + * - An object with fields that represent the current status of the mutation's execution 25735 + * 25736 + * @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; 25737 + * 25738 + * @example 25739 + * const [gqlUpdateExchangeCredentialsMutation, { data, loading, error }] = useGQLUpdateExchangeCredentialsMutation({ 25740 + * variables: { 25741 + * apiName: // value for 'apiName' 25742 + * credentialsJson: // value for 'credentialsJson' 25743 + * }, 25744 + * }); 25745 + */ 25746 + export function useGQLUpdateExchangeCredentialsMutation( 25747 + baseOptions?: Apollo.MutationHookOptions< 25748 + GQLUpdateExchangeCredentialsMutation, 25749 + GQLUpdateExchangeCredentialsMutationVariables 25750 + >, 25751 + ) { 25752 + const options = { ...defaultOptions, ...baseOptions }; 25753 + return Apollo.useMutation< 25754 + GQLUpdateExchangeCredentialsMutation, 25755 + GQLUpdateExchangeCredentialsMutationVariables 25756 + >(GQLUpdateExchangeCredentialsDocument, options); 25757 + } 25758 + export type GQLUpdateExchangeCredentialsMutationHookResult = ReturnType< 25759 + typeof useGQLUpdateExchangeCredentialsMutation 25760 + >; 25761 + export type GQLUpdateExchangeCredentialsMutationResult = 25762 + Apollo.MutationResult<GQLUpdateExchangeCredentialsMutation>; 25763 + export type GQLUpdateExchangeCredentialsMutationOptions = 25764 + Apollo.BaseMutationOptions< 25765 + GQLUpdateExchangeCredentialsMutation, 25766 + GQLUpdateExchangeCredentialsMutationVariables 25767 + >; 25430 25768 export const GQLUserAndOrgDocument = gql` 25431 25769 query UserAndOrg { 25432 25770 me { ··· 37476 37814 ApiAuth: 'ApiAuth', 37477 37815 HashBanks: 'HashBanks', 37478 37816 HashBankById: 'HashBankById', 37817 + ExchangeApis: 'ExchangeApis', 37818 + ExchangeApiSchema: 'ExchangeApiSchema', 37479 37819 UserAndOrg: 'UserAndOrg', 37480 37820 LoggedInUserForRoute: 'LoggedInUserForRoute', 37481 37821 PermissionGatedRouteLoggedInUser: 'PermissionGatedRouteLoggedInUser', ··· 37601 37941 CreateHashBank: 'CreateHashBank', 37602 37942 UpdateHashBank: 'UpdateHashBank', 37603 37943 DeleteHashBank: 'DeleteHashBank', 37944 + UpdateExchangeCredentials: 'UpdateExchangeCredentials', 37604 37945 Login: 'Login', 37605 37946 DeleteRejectedUser: 'DeleteRejectedUser', 37606 37947 SignUp: 'SignUp',
+55
client/src/graphql/operations/hashBanks.ts
··· 22 22 hma_name 23 23 enabled_ratio 24 24 org_id 25 + exchange { 26 + api 27 + enabled 28 + has_auth 29 + error 30 + last_fetch_succeeded 31 + last_fetch_time 32 + up_to_date 33 + fetched_items 34 + is_fetching 35 + } 36 + } 37 + } 38 + `; 39 + 40 + export const EXCHANGE_APIS_QUERY = gql` 41 + query ExchangeApis { 42 + exchangeApis { 43 + name 44 + supports_auth 45 + has_auth 46 + } 47 + } 48 + `; 49 + 50 + export const EXCHANGE_API_SCHEMA_QUERY = gql` 51 + query ExchangeApiSchema($apiName: String!) { 52 + exchangeApiSchema(apiName: $apiName) { 53 + config_schema { 54 + fields { 55 + name 56 + type 57 + required 58 + default 59 + help 60 + choices 61 + } 62 + } 63 + credentials_schema { 64 + fields { 65 + name 66 + type 67 + required 68 + default 69 + help 70 + choices 71 + } 72 + } 25 73 } 26 74 } 27 75 `; ··· 38 86 enabled_ratio 39 87 org_id 40 88 } 89 + warning 41 90 } 42 91 ... on MatchingBankNameExistsError { 43 92 title ··· 79 128 export const DELETE_HASH_BANK_MUTATION = gql` 80 129 mutation DeleteHashBank($id: ID!) { 81 130 deleteHashBank(id: $id) 131 + } 132 + `; 133 + 134 + export const UPDATE_EXCHANGE_CREDENTIALS_MUTATION = gql` 135 + mutation UpdateExchangeCredentials($apiName: String!, $credentialsJson: String!) { 136 + updateExchangeCredentials(apiName: $apiName, credentialsJson: $credentialsJson) 82 137 } 83 138 `;
+503 -30
client/src/webpages/dashboard/banks/hash/HashBankForm.tsx
··· 1 - import React, { useMemo, useState } from 'react'; 2 - import { Form, Slider } from 'antd'; 1 + import React, { useCallback, useEffect, useMemo, useState } from 'react'; 2 + import { Button, Form, Input, Select, Slider, Switch, Tag } from 'antd'; 3 3 import { useNavigate, useParams } from 'react-router-dom'; 4 4 import { Helmet } from 'react-helmet-async'; 5 - import { 6 - useGQLCreateHashBankMutation, 5 + import { 6 + useGQLCreateHashBankMutation, 7 7 useGQLUpdateHashBankMutation, 8 + useGQLUpdateExchangeCredentialsMutation, 8 9 useGQLHashBankByIdQuery, 10 + useGQLExchangeApisQuery, 11 + useGQLExchangeApiSchemaLazyQuery, 9 12 namedOperations, 10 - GQLHashBankByIdDocument 13 + GQLHashBankByIdDocument, 14 + type GQLExchangeApiSchemaQuery, 11 15 } from '../../../../graphql/generated'; 12 16 import CoopModal from '../../components/CoopModal'; 13 17 import FormHeader from '../../components/FormHeader'; ··· 16 20 import CoopButton from '../../components/CoopButton'; 17 21 import FullScreenLoading from '../../../../components/common/FullScreenLoading'; 18 22 23 + type SchemaField = GQLExchangeApiSchemaQuery['exchangeApiSchema'] extends 24 + | infer S 25 + | null 26 + | undefined 27 + ? S extends { config_schema: { fields: ReadonlyArray<infer F> } } 28 + ? F 29 + : never 30 + : never; 31 + 32 + const EXCHANGE_DISPLAY_NAMES: Record<string, string> = { 33 + fb_threatexchange: 'Facebook ThreatExchange', 34 + ncmec: 'NCMEC', 35 + stop_ncii: 'StopNCII', 36 + }; 37 + 38 + function formatEnumChoice(value: string): string { 39 + return value; 40 + } 41 + 19 42 const getSliderColor = (value: number) => { 20 - if (value === 0) return '#ff4d4f'; // red 21 - if (value < 1) return '#faad14'; // yellow 22 - return '#ffffff'; // white 43 + if (value === 0) return '#ff4d4f'; 44 + if (value < 1) return '#faad14'; 45 + return '#ffffff'; 23 46 }; 24 47 48 + function isCollectionType(type: string): boolean { 49 + return type.startsWith('set_of_') || type.startsWith('list_of_'); 50 + } 51 + 52 + function coerceFieldValue(field: SchemaField, raw: string): unknown { 53 + if (field.type === 'number') { 54 + const n = Number(raw); 55 + return Number.isNaN(n) ? raw : n; 56 + } 57 + if (field.type === 'boolean') { 58 + return raw === 'true'; 59 + } 60 + if (isCollectionType(field.type)) { 61 + if (!raw.trim()) return []; 62 + const innerType = field.type.replace(/^(set_of_|list_of_)/, ''); 63 + return raw 64 + .split(',') 65 + .map((s) => s.trim()) 66 + .filter(Boolean) 67 + .map((s) => (innerType === 'number' ? Number(s) : s)); 68 + } 69 + return raw; 70 + } 71 + 72 + function displayCollectionValue(value: unknown): string { 73 + if (Array.isArray(value)) { 74 + return value.join(', '); 75 + } 76 + return String(value ?? ''); 77 + } 78 + 79 + function DynamicSchemaFields({ 80 + title, 81 + subtitle, 82 + fields, 83 + values, 84 + onChange, 85 + }: { 86 + title: string; 87 + subtitle?: string; 88 + fields: ReadonlyArray<SchemaField>; 89 + values: Record<string, unknown>; 90 + onChange: (values: Record<string, unknown>) => void; 91 + }) { 92 + const handleFieldChange = useCallback( 93 + (fieldName: string, field: SchemaField, raw: string) => { 94 + onChange({ ...values, [fieldName]: coerceFieldValue(field, raw) }); 95 + }, 96 + [values, onChange] 97 + ); 98 + 99 + if (fields.length === 0) return null; 100 + 101 + return ( 102 + <div className="flex flex-col justify-start"> 103 + <FormSectionHeader title={title} subtitle={subtitle} /> 104 + <div className="flex flex-col gap-4"> 105 + {fields.map((field) => { 106 + const label = field.name 107 + .replace(/_/g, ' ') 108 + .replace(/\b\w/g, (c) => c.toUpperCase()); 109 + 110 + return ( 111 + <div key={field.name} className="flex flex-col gap-1"> 112 + <label className="text-sm font-medium text-zinc-700"> 113 + {label} 114 + {field.required && <span className="ml-1 text-red-500">*</span>} 115 + </label> 116 + {field.help && ( 117 + <span className="text-xs text-zinc-500">{field.help}</span> 118 + )} 119 + 120 + {field.type === 'enum' && field.choices ? ( 121 + <Select 122 + value={ 123 + values[field.name] != null 124 + ? String(values[field.name]) 125 + : undefined 126 + } 127 + placeholder={`Select ${label}`} 128 + onChange={(val) => handleFieldChange(field.name, field, val)} 129 + options={field.choices.map((c) => ({ 130 + label: formatEnumChoice(c), 131 + value: c, 132 + }))} 133 + className="max-w-md" 134 + /> 135 + ) : field.type === 'boolean' ? ( 136 + <Switch 137 + checked={values[field.name] === true} 138 + onChange={(checked) => 139 + onChange({ ...values, [field.name]: checked }) 140 + } 141 + /> 142 + ) : isCollectionType(field.type) ? ( 143 + <Input 144 + value={displayCollectionValue(values[field.name])} 145 + placeholder="Enter comma-separated values" 146 + onChange={(e) => 147 + handleFieldChange(field.name, field, e.target.value) 148 + } 149 + className="max-w-md" 150 + /> 151 + ) : ( 152 + <Input 153 + value={ 154 + values[field.name] != null 155 + ? String(values[field.name]) 156 + : '' 157 + } 158 + placeholder={ 159 + field.type === 'number' 160 + ? 'Enter a number' 161 + : `Enter ${label}` 162 + } 163 + type={field.type === 'number' ? 'number' : 'text'} 164 + onChange={(e) => 165 + handleFieldChange(field.name, field, e.target.value) 166 + } 167 + className="max-w-md" 168 + /> 169 + )} 170 + </div> 171 + ); 172 + })} 173 + </div> 174 + </div> 175 + ); 176 + } 177 + 25 178 export default function HashBankForm() { 26 179 const navigate = useNavigate(); 27 180 const { id } = useParams<{ id?: string }>(); ··· 37 190 const [bankDescription, setBankDescription] = useState(''); 38 191 const [enabledRatio, setEnabledRatio] = useState(1.0); 39 192 40 - const showModal = () => { 41 - setModalVisible(true); 42 - }; 193 + const [selectedExchangeApi, setSelectedExchangeApi] = useState<string | null>(null); 194 + const [exchangeConfigValues, setExchangeConfigValues] = useState<Record<string, unknown>>({}); 195 + const [exchangeCredValues, setExchangeCredValues] = useState<Record<string, unknown>>({}); 196 + 197 + const isCreating = id == null; 198 + 199 + const showModal = () => setModalVisible(true); 200 + const hideModal = () => setModalVisible(false); 201 + 202 + const exchangeApisQuery = useGQLExchangeApisQuery({ skip: !isCreating }); 203 + const exchangeApis = useMemo( 204 + () => exchangeApisQuery.data?.exchangeApis ?? [], 205 + [exchangeApisQuery.data?.exchangeApis] 206 + ); 207 + 208 + const [fetchSchema, schemaQuery] = useGQLExchangeApiSchemaLazyQuery(); 209 + const schema = schemaQuery.data?.exchangeApiSchema; 210 + const schemaLoading = schemaQuery.loading; 211 + 212 + const selectedApiInfo = useMemo( 213 + () => exchangeApis.find((a) => a.name === selectedExchangeApi), 214 + [exchangeApis, selectedExchangeApi] 215 + ); 216 + 217 + useEffect(() => { 218 + if (selectedExchangeApi) { 219 + fetchSchema({ variables: { apiName: selectedExchangeApi } }); 220 + setExchangeConfigValues({}); 221 + setExchangeCredValues({}); 222 + } 223 + }, [selectedExchangeApi, fetchSchema]); 224 + 225 + const [editCredValues, setEditCredValues] = useState<Record<string, unknown>>({}); 226 + const [showCredForm, setShowCredForm] = useState(false); 227 + 228 + useEffect(() => { 229 + if (schema) { 230 + const configDefaults: Record<string, unknown> = {}; 231 + for (const f of schema.config_schema.fields) { 232 + if (f.default != null) configDefaults[f.name] = f.default; 233 + } 234 + setExchangeConfigValues(configDefaults); 43 235 44 - const hideModal = () => { 45 - setModalVisible(false); 46 - }; 236 + if (schema.credentials_schema) { 237 + const credDefaults: Record<string, unknown> = {}; 238 + for (const f of schema.credentials_schema.fields) { 239 + if (f.default != null) credDefaults[f.name] = f.default; 240 + } 241 + setExchangeCredValues(credDefaults); 242 + } 243 + } 244 + }, [schema]); 47 245 48 246 const [createHashBank, createMutationParams] = useGQLCreateHashBankMutation({ 49 - onError: (e) => { 247 + onError: () => { 50 248 setModalInfo({ 51 249 title: 'Error Creating Hash Bank', 52 250 body: 'We encountered an error trying to create your Hash Bank. Please try again.', ··· 57 255 onCompleted: (result) => { 58 256 const response = result.createHashBank; 59 257 if ('data' in response) { 60 - setModalInfo({ 61 - title: 'Hash Bank Created', 62 - body: 'Your Hash Bank was successfully created!', 63 - buttonText: 'Done', 64 - }); 258 + const warning = 'warning' in response ? response.warning : null; 259 + if (warning) { 260 + setModalInfo({ 261 + title: 'Hash Bank Created with Warning', 262 + body: String(warning), 263 + buttonText: 'OK', 264 + }); 265 + } else { 266 + setModalInfo({ 267 + title: 'Hash Bank Created', 268 + body: selectedExchangeApi 269 + ? 'Your Hash Bank was successfully created and connected to the exchange!' 270 + : 'Your Hash Bank was successfully created!', 271 + buttonText: 'Done', 272 + }); 273 + } 65 274 showModal(); 66 275 return; 67 276 } ··· 78 287 }); 79 288 80 289 const [updateHashBank, updateMutationParams] = useGQLUpdateHashBankMutation({ 81 - onError: (e) => { 290 + onError: () => { 82 291 setModalInfo({ 83 292 title: 'Error Updating Hash Bank', 84 293 body: 'We encountered an error trying to update your Hash Bank. Please try again.', ··· 131 340 } 132 341 }, [bank, form]); 133 342 343 + const bankExchangeApi = bank?.exchange?.api; 344 + 345 + const [updateExchangeCredentials, updateCredsMutationParams] = 346 + useGQLUpdateExchangeCredentialsMutation({ 347 + onError: () => { 348 + setModalInfo({ 349 + title: 'Error Updating Credentials', 350 + body: 'We encountered an error trying to update the exchange credentials. Please try again.', 351 + buttonText: 'OK', 352 + }); 353 + showModal(); 354 + }, 355 + onCompleted: () => { 356 + setModalInfo({ 357 + title: 'Credentials Updated', 358 + body: 'Exchange credentials have been updated successfully.', 359 + buttonText: 'Done', 360 + }); 361 + showModal(); 362 + setEditCredValues({}); 363 + setShowCredForm(false); 364 + }, 365 + }); 366 + 367 + useEffect(() => { 368 + if (!isCreating && bankExchangeApi) { 369 + fetchSchema({ variables: { apiName: bankExchangeApi } }); 370 + } 371 + }, [isCreating, bankExchangeApi, fetchSchema]); 372 + 134 373 if (bankQueryError) { 135 374 throw bankQueryError; 136 375 } ··· 139 378 } 140 379 141 380 const onCreateBank = async () => { 381 + const exchangeInput = 382 + selectedExchangeApi && schema 383 + ? { 384 + api_name: selectedExchangeApi, 385 + config_json: JSON.stringify(exchangeConfigValues), 386 + credentials_json: 387 + schema.credentials_schema && 388 + selectedApiInfo && 389 + !selectedApiInfo.has_auth 390 + ? JSON.stringify(exchangeCredValues) 391 + : undefined, 392 + } 393 + : undefined; 394 + 142 395 createHashBank({ 143 396 variables: { 144 397 input: { 145 398 name: bankName, 146 399 description: bankDescription, 147 400 enabled_ratio: enabledRatio, 401 + exchange: exchangeInput, 148 402 }, 149 403 }, 150 404 refetchQueries: [namedOperations.Query.HashBanks], ··· 168 422 }); 169 423 }; 170 424 425 + const onUpdateCredentials = () => { 426 + if (!bankExchangeApi) return; 427 + updateExchangeCredentials({ 428 + variables: { 429 + apiName: bankExchangeApi, 430 + credentialsJson: JSON.stringify(editCredValues), 431 + }, 432 + refetchQueries: [ 433 + namedOperations.Query.HashBanks, 434 + { query: GQLHashBankByIdDocument, variables: { id } }, 435 + ], 436 + }); 437 + }; 438 + 171 439 const onHideModal = () => { 172 440 hideModal(); 173 441 ··· 179 447 } 180 448 }; 181 449 450 + const hasRequiredConfigMissing = 451 + selectedExchangeApi && 452 + schema?.config_schema.fields.some( 453 + (f) => 454 + f.required && 455 + (exchangeConfigValues[f.name] == null || 456 + exchangeConfigValues[f.name] === '') 457 + ); 458 + 459 + const hasRequiredCredsMissing = 460 + selectedExchangeApi && 461 + schema?.credentials_schema && 462 + selectedApiInfo && 463 + !selectedApiInfo.has_auth && 464 + schema.credentials_schema.fields.some( 465 + (f) => 466 + f.required && 467 + (exchangeCredValues[f.name] == null || 468 + exchangeCredValues[f.name] === '') 469 + ); 470 + 471 + const isExchangeIncomplete = Boolean(hasRequiredConfigMissing) || Boolean(hasRequiredCredsMissing); 472 + 182 473 const modal = ( 183 474 <CoopModal 184 475 title={modalInfo.title} ··· 196 487 </CoopModal> 197 488 ); 198 489 490 + const exchangeApiDisplayName = bank?.exchange 491 + ? EXCHANGE_DISPLAY_NAMES[bank.exchange.api] ?? bank.exchange.api 492 + : null; 493 + 199 494 return ( 200 495 <div className="flex flex-col text-start"> 201 496 <Helmet> 202 - <title>{id == null ? 'Create Hash Bank' : 'Update Hash Bank'}</title> 497 + <title>{isCreating ? 'Create Hash Bank' : 'Update Hash Bank'}</title> 203 498 </Helmet> 204 499 <FormHeader 205 - title={id == null ? 'Create Hash Bank' : 'Update Hash Bank'} 500 + title={isCreating ? 'Create Hash Bank' : 'Update Hash Bank'} 206 501 /> 502 + 503 + {!isCreating && bank?.exchange && ( 504 + <> 505 + {bank.exchange.error ? ( 506 + <div className="flex items-center gap-3 p-4 mb-6 border rounded-lg bg-red-50 border-red-200"> 507 + <div className="flex flex-col"> 508 + <span className="text-sm font-medium text-red-900"> 509 + Exchange Error 510 + </span> 511 + <span className="mt-1 text-sm text-red-700"> 512 + {bank.exchange.error} 513 + </span> 514 + </div> 515 + </div> 516 + ) : ( 517 + <> 518 + <div className="flex items-center justify-between p-4 mb-6 border rounded-lg bg-blue-50 border-blue-200"> 519 + <div className="flex flex-col gap-2"> 520 + <span className="text-sm font-medium text-blue-900"> 521 + Connected to Exchange 522 + </span> 523 + <div className="flex flex-wrap items-center gap-2"> 524 + <Tag color="blue">{exchangeApiDisplayName}</Tag> 525 + <Tag color={bank.exchange.enabled ? 'green' : 'default'}> 526 + {bank.exchange.enabled ? 'Enabled' : 'Disabled'} 527 + </Tag> 528 + <Tag color={bank.exchange.has_auth ? 'green' : 'orange'}> 529 + {bank.exchange.has_auth ? 'Credentials Set' : 'Credentials Missing'} 530 + </Tag> 531 + {bank.exchange.last_fetch_succeeded === false && ( 532 + <Tag color="red">Fetch Failed</Tag> 533 + )} 534 + {bank.exchange.last_fetch_succeeded === true && ( 535 + <Tag color="green">Fetch OK</Tag> 536 + )} 537 + {bank.exchange.is_fetching && ( 538 + <Tag color="processing">Fetching...</Tag> 539 + )} 540 + </div> 541 + {bank.exchange.last_fetch_time && ( 542 + <span className="text-xs text-zinc-500"> 543 + Last fetch: {new Date(bank.exchange.last_fetch_time).toLocaleString()} 544 + {bank.exchange.fetched_items != null && ( 545 + <> &middot; {bank.exchange.fetched_items} items</> 546 + )} 547 + </span> 548 + )} 549 + {bank.exchange.last_fetch_succeeded === false && ( 550 + <span className="text-xs text-red-600"> 551 + The last fetch from this exchange failed. Check that credentials are correct and the exchange service is reachable. 552 + </span> 553 + )} 554 + </div> 555 + {schema?.credentials_schema && 556 + schema.credentials_schema.fields.length > 0 && ( 557 + <Button 558 + type="link" 559 + onClick={() => { 560 + setShowCredForm((prev) => !prev); 561 + if (showCredForm) setEditCredValues({}); 562 + }} 563 + > 564 + {showCredForm ? 'Cancel' : 'Update Credentials'} 565 + </Button> 566 + )} 567 + </div> 568 + 569 + {showCredForm && 570 + schema?.credentials_schema && 571 + schema.credentials_schema.fields.length > 0 && ( 572 + <div className="mb-6"> 573 + <DynamicSchemaFields 574 + title="Update Exchange Credentials" 575 + subtitle="Enter new credentials to replace the existing ones." 576 + fields={schema.credentials_schema.fields} 577 + values={editCredValues} 578 + onChange={setEditCredValues} 579 + /> 580 + <div className="mt-4"> 581 + <CoopButton 582 + title="Save Credentials" 583 + loading={updateCredsMutationParams.loading} 584 + disabled={ 585 + schema.credentials_schema.fields 586 + .filter((f) => f.required) 587 + .some( 588 + (f) => 589 + editCredValues[f.name] == null || 590 + editCredValues[f.name] === '' 591 + ) 592 + } 593 + disabledTooltipTitle="Please fill in all required credential fields" 594 + onClick={onUpdateCredentials} 595 + /> 596 + </div> 597 + <div className="mt-5 divider mb-9" /> 598 + </div> 599 + )} 600 + </> 601 + )} 602 + </> 603 + )} 604 + 207 605 <NameDescriptionInput 208 606 nameInitialValue={bankName} 209 607 descriptionInitialValue={bankDescription} 210 608 onChangeName={setBankName} 211 609 onChangeDescription={setBankDescription} 212 610 /> 213 - 611 + 214 612 <div className="mt-5 divider mb-9" /> 215 - 613 + 216 614 <div className="flex flex-col justify-start"> 217 615 <FormSectionHeader 218 616 title="Enabled Ratio" ··· 245 643 0 = Fully disabled, 1 = Fully enabled 246 644 </div> 247 645 </div> 248 - 646 + 249 647 <div className="mt-5 divider mb-9" /> 250 - 648 + 649 + {isCreating && ( 650 + <> 651 + <div className="flex flex-col justify-start"> 652 + <FormSectionHeader 653 + title="Exchange Connection" 654 + subtitle="Optionally connect this bank to a signal exchange to automatically receive shared threat intelligence data." 655 + /> 656 + <Select 657 + value={selectedExchangeApi ?? undefined} 658 + placeholder="No exchange (standalone bank)" 659 + allowClear 660 + onChange={(val) => setSelectedExchangeApi(val ?? null)} 661 + loading={exchangeApisQuery.loading} 662 + className="max-w-md" 663 + options={exchangeApis.map((api) => ({ 664 + label: EXCHANGE_DISPLAY_NAMES[api.name] ?? api.name.replace(/_/g, ' '), 665 + value: api.name, 666 + }))} 667 + /> 668 + </div> 669 + 670 + {selectedExchangeApi && schemaLoading && ( 671 + <div className="flex items-center gap-2 mt-4 text-sm text-zinc-500"> 672 + <div className="w-4 h-4 border-2 rounded-full border-zinc-400 border-t-transparent animate-spin" /> 673 + Loading exchange configuration... 674 + </div> 675 + )} 676 + 677 + {selectedExchangeApi && schema && !schemaLoading && ( 678 + <> 679 + {schema.config_schema.fields.length > 0 && ( 680 + <div className="mt-6"> 681 + <DynamicSchemaFields 682 + title="Exchange Configuration" 683 + subtitle="Configure the exchange-specific settings for this connection." 684 + fields={schema.config_schema.fields} 685 + values={exchangeConfigValues} 686 + onChange={setExchangeConfigValues} 687 + /> 688 + </div> 689 + )} 690 + 691 + {schema.credentials_schema && 692 + selectedApiInfo && 693 + !selectedApiInfo.has_auth && ( 694 + <div className="mt-6"> 695 + <DynamicSchemaFields 696 + title="Exchange Credentials" 697 + subtitle="Provide authentication credentials for this exchange API. These credentials are shared across all exchanges of this type." 698 + fields={schema.credentials_schema.fields} 699 + values={exchangeCredValues} 700 + onChange={setExchangeCredValues} 701 + /> 702 + </div> 703 + )} 704 + 705 + {schema.credentials_schema && 706 + selectedApiInfo?.has_auth && ( 707 + <div className="mt-4 p-3 text-sm rounded-md bg-emerald-50 text-emerald-700"> 708 + Credentials for this exchange API are already configured. 709 + </div> 710 + )} 711 + </> 712 + )} 713 + 714 + <div className="mt-5 divider mb-9" /> 715 + </> 716 + )} 717 + 251 718 <CoopButton 252 - title={id == null ? 'Create Hash Bank' : 'Save Changes'} 719 + title={isCreating ? 'Create Hash Bank' : 'Save Changes'} 253 720 loading={updateMutationParams.loading || createMutationParams.loading} 254 - onClick={id == null ? onCreateBank : onUpdateBank} 721 + disabled={isCreating && Boolean(isExchangeIncomplete)} 722 + disabledTooltipTitle={ 723 + isExchangeIncomplete 724 + ? 'Please fill in all required exchange fields' 725 + : undefined 726 + } 727 + onClick={isCreating ? onCreateBank : onUpdateBank} 255 728 /> 256 729 {modal} 257 730 </div> 258 731 ); 259 - } 732 + }
+2
codegen.yaml
··· 40 40 Date: Date | string 41 41 DateTime: Date | string 42 42 Cursor: string 43 + JSON: JsonValue 43 44 JSONObject: JsonObject 44 45 CoopInputOrString: string 45 46 StringOrFloat: string | number ··· 77 78 Date: Date | string 78 79 DateTime: Date | string 79 80 Cursor: JsonValue 81 + JSON: JsonValue 80 82 JSONObject: JsonObject 81 83 CoopInputOrString: string 82 84 StringOrFloat: string | number
+1 -1
hma/Dockerfile
··· 1 - FROM ghcr.io/facebook/threatexchange/hma:1.1.1 1 + FROM ghcr.io/facebook/threatexchange/hma:1.1.2 2 2 3 3 WORKDIR /app 4 4
-2
hma/omm_config.py
··· 53 53 signal_types=[PdqSignal, VideoMD5Signal], 54 54 content_types=[PhotoContent, VideoContent], 55 55 exchange_types=[ 56 - StaticSampleSignalExchangeAPI, 57 - InfiniteRandomExchange, # type: ignore 58 56 FBThreatExchangeSignalExchangeAPI, # type: ignore 59 57 NCMECSignalExchangeAPI, # type: ignore 60 58 StopNCIISignalExchangeAPI,
+220
server/graphql/generated.ts
··· 105 105 * fields. 106 106 */ 107 107 DateTime: Date | string; 108 + /** Represents any JSON value (object, array, string, number, boolean, null). */ 109 + JSON: JsonValue; 108 110 /** Represents an arbitrary json object. */ 109 111 JSONObject: JsonObject; 110 112 /** Represents a string that must be non-empty. */ ··· 751 753 export type GQLCreateHashBankInput = { 752 754 readonly description?: InputMaybe<Scalars['String']>; 753 755 readonly enabled_ratio: Scalars['Float']; 756 + readonly exchange?: InputMaybe<GQLExchangeConfigInput>; 754 757 readonly name: Scalars['String']; 755 758 }; 756 759 ··· 1146 1149 readonly type: ReadonlyArray<Scalars['String']>; 1147 1150 }; 1148 1151 1152 + export type GQLExchangeApiInfo = { 1153 + readonly __typename?: 'ExchangeApiInfo'; 1154 + readonly has_auth: Scalars['Boolean']; 1155 + readonly name: Scalars['String']; 1156 + readonly supports_auth: Scalars['Boolean']; 1157 + }; 1158 + 1159 + export type GQLExchangeApiSchema = { 1160 + readonly __typename?: 'ExchangeApiSchema'; 1161 + readonly config_schema: GQLExchangeSchemaSection; 1162 + readonly credentials_schema?: Maybe<GQLExchangeSchemaSection>; 1163 + }; 1164 + 1165 + export type GQLExchangeConfigInput = { 1166 + readonly api_name: Scalars['String']; 1167 + readonly config_json: Scalars['String']; 1168 + readonly credentials_json?: InputMaybe<Scalars['String']>; 1169 + }; 1170 + 1171 + export type GQLExchangeFieldDescriptor = { 1172 + readonly __typename?: 'ExchangeFieldDescriptor'; 1173 + readonly choices?: Maybe<ReadonlyArray<Scalars['String']>>; 1174 + readonly default?: Maybe<Scalars['JSON']>; 1175 + readonly help?: Maybe<Scalars['String']>; 1176 + readonly name: Scalars['String']; 1177 + readonly required: Scalars['Boolean']; 1178 + readonly type: Scalars['String']; 1179 + }; 1180 + 1181 + export type GQLExchangeInfo = { 1182 + readonly __typename?: 'ExchangeInfo'; 1183 + readonly api: Scalars['String']; 1184 + readonly enabled: Scalars['Boolean']; 1185 + readonly error?: Maybe<Scalars['String']>; 1186 + readonly fetched_items?: Maybe<Scalars['Int']>; 1187 + readonly has_auth: Scalars['Boolean']; 1188 + readonly is_fetching?: Maybe<Scalars['Boolean']>; 1189 + readonly last_fetch_succeeded?: Maybe<Scalars['Boolean']>; 1190 + readonly last_fetch_time?: Maybe<Scalars['String']>; 1191 + readonly up_to_date?: Maybe<Scalars['Boolean']>; 1192 + }; 1193 + 1194 + export type GQLExchangeSchemaSection = { 1195 + readonly __typename?: 'ExchangeSchemaSection'; 1196 + readonly fields: ReadonlyArray<GQLExchangeFieldDescriptor>; 1197 + }; 1198 + 1149 1199 export type GQLExecuteActionResponse = { 1150 1200 readonly __typename?: 'ExecuteActionResponse'; 1151 1201 readonly actionId: Scalars['String']; ··· 1299 1349 readonly __typename?: 'HashBank'; 1300 1350 readonly description?: Maybe<Scalars['String']>; 1301 1351 readonly enabled_ratio: Scalars['Float']; 1352 + readonly exchange?: Maybe<GQLExchangeInfo>; 1302 1353 readonly hma_name: Scalars['String']; 1303 1354 readonly id: Scalars['ID']; 1304 1355 readonly name: Scalars['String']; ··· 2267 2318 export type GQLMutateHashBankSuccessResponse = { 2268 2319 readonly __typename?: 'MutateHashBankSuccessResponse'; 2269 2320 readonly data: GQLHashBank; 2321 + readonly warning?: Maybe<Scalars['String']>; 2270 2322 }; 2271 2323 2272 2324 export type GQLMutateLocationBankResponse = ··· 2390 2442 readonly updateAppealSettings: GQLAppealSettings; 2391 2443 readonly updateContentItemType: GQLMutateContentItemTypeResponse; 2392 2444 readonly updateContentRule: GQLUpdateContentRuleResponse; 2445 + readonly updateExchangeCredentials: Scalars['Boolean']; 2393 2446 readonly updateHashBank: GQLMutateHashBankResponse; 2394 2447 readonly updateLocationBank: GQLMutateLocationBankResponse; 2395 2448 readonly updateManualReviewQueue: GQLUpdateManualReviewQueueQueueResponse; ··· 2668 2721 input: GQLUpdateContentRuleInput; 2669 2722 }; 2670 2723 2724 + export type GQLMutationUpdateExchangeCredentialsArgs = { 2725 + apiName: Scalars['String']; 2726 + credentialsJson: Scalars['String']; 2727 + }; 2728 + 2671 2729 export type GQLMutationUpdateHashBankArgs = { 2672 2730 input: GQLUpdateHashBankInput; 2673 2731 }; ··· 3162 3220 readonly apiKey: Scalars['String']; 3163 3221 readonly appealSettings?: Maybe<GQLAppealSettings>; 3164 3222 readonly availableIntegrations: ReadonlyArray<GQLIntegrationMetadata>; 3223 + readonly exchangeApiSchema?: Maybe<GQLExchangeApiSchema>; 3224 + readonly exchangeApis: ReadonlyArray<GQLExchangeApiInfo>; 3165 3225 readonly getCommentsForJob: ReadonlyArray<GQLManualReviewJobComment>; 3166 3226 readonly getDecidedJob?: Maybe<GQLManualReviewJob>; 3167 3227 readonly getDecidedJobFromJobId?: Maybe<GQLManualReviewJobWithDecisions>; ··· 3224 3284 3225 3285 export type GQLQueryActionStatisticsArgs = { 3226 3286 input: GQLActionStatisticsInput; 3287 + }; 3288 + 3289 + export type GQLQueryExchangeApiSchemaArgs = { 3290 + apiName: Scalars['String']; 3227 3291 }; 3228 3292 3229 3293 export type GQLQueryGetCommentsForJobArgs = { ··· 5230 5294 | GQLResolversTypes['RuleNameExistsError'] 5231 5295 | GQLResolversTypes['SignUpUserExistsError'] 5232 5296 | GQLResolversTypes['SubmittedJobActionNotFoundError']; 5297 + ExchangeApiInfo: ResolverTypeWrapper<GQLExchangeApiInfo>; 5298 + ExchangeApiSchema: ResolverTypeWrapper<GQLExchangeApiSchema>; 5299 + ExchangeConfigInput: GQLExchangeConfigInput; 5300 + ExchangeFieldDescriptor: ResolverTypeWrapper<GQLExchangeFieldDescriptor>; 5301 + ExchangeInfo: ResolverTypeWrapper<GQLExchangeInfo>; 5302 + ExchangeSchemaSection: ResolverTypeWrapper<GQLExchangeSchemaSection>; 5233 5303 ExecuteActionResponse: ResolverTypeWrapper<GQLExecuteActionResponse>; 5234 5304 ExecuteBulkActionInput: GQLExecuteBulkActionInput; 5235 5305 ExecuteBulkActionResponse: ResolverTypeWrapper<GQLExecuteBulkActionResponse>; ··· 5331 5401 parents: ReadonlyArray<GQLResolversTypes['ItemSubmissions']>; 5332 5402 } 5333 5403 >; 5404 + JSON: ResolverTypeWrapper<Scalars['JSON']>; 5334 5405 JSONObject: ResolverTypeWrapper<Scalars['JSONObject']>; 5335 5406 JobCountFilterByInput: GQLJobCountFilterByInput; 5336 5407 JobCountGroupByColumns: GQLJobCountGroupByColumns; ··· 6086 6157 | GQLResolversParentTypes['RuleNameExistsError'] 6087 6158 | GQLResolversParentTypes['SignUpUserExistsError'] 6088 6159 | GQLResolversParentTypes['SubmittedJobActionNotFoundError']; 6160 + ExchangeApiInfo: GQLExchangeApiInfo; 6161 + ExchangeApiSchema: GQLExchangeApiSchema; 6162 + ExchangeConfigInput: GQLExchangeConfigInput; 6163 + ExchangeFieldDescriptor: GQLExchangeFieldDescriptor; 6164 + ExchangeInfo: GQLExchangeInfo; 6165 + ExchangeSchemaSection: GQLExchangeSchemaSection; 6089 6166 ExecuteActionResponse: GQLExecuteActionResponse; 6090 6167 ExecuteBulkActionInput: GQLExecuteBulkActionInput; 6091 6168 ExecuteBulkActionResponse: GQLExecuteBulkActionResponse; ··· 6173 6250 item: GQLResolversParentTypes['ItemSubmissions']; 6174 6251 parents: ReadonlyArray<GQLResolversParentTypes['ItemSubmissions']>; 6175 6252 }; 6253 + JSON: Scalars['JSON']; 6176 6254 JSONObject: Scalars['JSONObject']; 6177 6255 JobCountFilterByInput: GQLJobCountFilterByInput; 6178 6256 JobCreationCount: GQLJobCreationCount; ··· 8358 8436 >; 8359 8437 }; 8360 8438 8439 + export type GQLExchangeApiInfoResolvers< 8440 + ContextType = Context, 8441 + ParentType extends 8442 + GQLResolversParentTypes['ExchangeApiInfo'] = GQLResolversParentTypes['ExchangeApiInfo'], 8443 + > = { 8444 + has_auth?: Resolver<GQLResolversTypes['Boolean'], ParentType, ContextType>; 8445 + name?: Resolver<GQLResolversTypes['String'], ParentType, ContextType>; 8446 + supports_auth?: Resolver< 8447 + GQLResolversTypes['Boolean'], 8448 + ParentType, 8449 + ContextType 8450 + >; 8451 + __isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>; 8452 + }; 8453 + 8454 + export type GQLExchangeApiSchemaResolvers< 8455 + ContextType = Context, 8456 + ParentType extends 8457 + GQLResolversParentTypes['ExchangeApiSchema'] = GQLResolversParentTypes['ExchangeApiSchema'], 8458 + > = { 8459 + config_schema?: Resolver< 8460 + GQLResolversTypes['ExchangeSchemaSection'], 8461 + ParentType, 8462 + ContextType 8463 + >; 8464 + credentials_schema?: Resolver< 8465 + Maybe<GQLResolversTypes['ExchangeSchemaSection']>, 8466 + ParentType, 8467 + ContextType 8468 + >; 8469 + __isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>; 8470 + }; 8471 + 8472 + export type GQLExchangeFieldDescriptorResolvers< 8473 + ContextType = Context, 8474 + ParentType extends 8475 + GQLResolversParentTypes['ExchangeFieldDescriptor'] = GQLResolversParentTypes['ExchangeFieldDescriptor'], 8476 + > = { 8477 + choices?: Resolver< 8478 + Maybe<ReadonlyArray<GQLResolversTypes['String']>>, 8479 + ParentType, 8480 + ContextType 8481 + >; 8482 + default?: Resolver<Maybe<GQLResolversTypes['JSON']>, ParentType, ContextType>; 8483 + help?: Resolver<Maybe<GQLResolversTypes['String']>, ParentType, ContextType>; 8484 + name?: Resolver<GQLResolversTypes['String'], ParentType, ContextType>; 8485 + required?: Resolver<GQLResolversTypes['Boolean'], ParentType, ContextType>; 8486 + type?: Resolver<GQLResolversTypes['String'], ParentType, ContextType>; 8487 + __isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>; 8488 + }; 8489 + 8490 + export type GQLExchangeInfoResolvers< 8491 + ContextType = Context, 8492 + ParentType extends 8493 + GQLResolversParentTypes['ExchangeInfo'] = GQLResolversParentTypes['ExchangeInfo'], 8494 + > = { 8495 + api?: Resolver<GQLResolversTypes['String'], ParentType, ContextType>; 8496 + enabled?: Resolver<GQLResolversTypes['Boolean'], ParentType, ContextType>; 8497 + error?: Resolver<Maybe<GQLResolversTypes['String']>, ParentType, ContextType>; 8498 + fetched_items?: Resolver< 8499 + Maybe<GQLResolversTypes['Int']>, 8500 + ParentType, 8501 + ContextType 8502 + >; 8503 + has_auth?: Resolver<GQLResolversTypes['Boolean'], ParentType, ContextType>; 8504 + is_fetching?: Resolver< 8505 + Maybe<GQLResolversTypes['Boolean']>, 8506 + ParentType, 8507 + ContextType 8508 + >; 8509 + last_fetch_succeeded?: Resolver< 8510 + Maybe<GQLResolversTypes['Boolean']>, 8511 + ParentType, 8512 + ContextType 8513 + >; 8514 + last_fetch_time?: Resolver< 8515 + Maybe<GQLResolversTypes['String']>, 8516 + ParentType, 8517 + ContextType 8518 + >; 8519 + up_to_date?: Resolver< 8520 + Maybe<GQLResolversTypes['Boolean']>, 8521 + ParentType, 8522 + ContextType 8523 + >; 8524 + __isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>; 8525 + }; 8526 + 8527 + export type GQLExchangeSchemaSectionResolvers< 8528 + ContextType = Context, 8529 + ParentType extends 8530 + GQLResolversParentTypes['ExchangeSchemaSection'] = GQLResolversParentTypes['ExchangeSchemaSection'], 8531 + > = { 8532 + fields?: Resolver< 8533 + ReadonlyArray<GQLResolversTypes['ExchangeFieldDescriptor']>, 8534 + ParentType, 8535 + ContextType 8536 + >; 8537 + __isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>; 8538 + }; 8539 + 8361 8540 export type GQLExecuteActionResponseResolvers< 8362 8541 ContextType = Context, 8363 8542 ParentType extends ··· 8512 8691 ContextType 8513 8692 >; 8514 8693 enabled_ratio?: Resolver<GQLResolversTypes['Float'], ParentType, ContextType>; 8694 + exchange?: Resolver< 8695 + Maybe<GQLResolversTypes['ExchangeInfo']>, 8696 + ParentType, 8697 + ContextType 8698 + >; 8515 8699 hma_name?: Resolver<GQLResolversTypes['String'], ParentType, ContextType>; 8516 8700 id?: Resolver<GQLResolversTypes['ID'], ParentType, ContextType>; 8517 8701 name?: Resolver<GQLResolversTypes['String'], ParentType, ContextType>; ··· 9111 9295 >; 9112 9296 __isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>; 9113 9297 }; 9298 + 9299 + export interface GQLJsonScalarConfig 9300 + extends GraphQLScalarTypeConfig<GQLResolversTypes['JSON'], any> { 9301 + name: 'JSON'; 9302 + } 9114 9303 9115 9304 export interface GQLJsonObjectScalarConfig 9116 9305 extends GraphQLScalarTypeConfig<GQLResolversTypes['JSONObject'], any> { ··· 10057 10246 GQLResolversParentTypes['MutateHashBankSuccessResponse'] = GQLResolversParentTypes['MutateHashBankSuccessResponse'], 10058 10247 > = { 10059 10248 data?: Resolver<GQLResolversTypes['HashBank'], ParentType, ContextType>; 10249 + warning?: Resolver< 10250 + Maybe<GQLResolversTypes['String']>, 10251 + ParentType, 10252 + ContextType 10253 + >; 10060 10254 __isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>; 10061 10255 }; 10062 10256 ··· 10597 10791 ParentType, 10598 10792 ContextType, 10599 10793 RequireFields<GQLMutationUpdateContentRuleArgs, 'input'> 10794 + >; 10795 + updateExchangeCredentials?: Resolver< 10796 + GQLResolversTypes['Boolean'], 10797 + ParentType, 10798 + ContextType, 10799 + RequireFields< 10800 + GQLMutationUpdateExchangeCredentialsArgs, 10801 + 'apiName' | 'credentialsJson' 10802 + > 10600 10803 >; 10601 10804 updateHashBank?: Resolver< 10602 10805 GQLResolversTypes['MutateHashBankResponse'], ··· 11556 11759 >; 11557 11760 availableIntegrations?: Resolver< 11558 11761 ReadonlyArray<GQLResolversTypes['IntegrationMetadata']>, 11762 + ParentType, 11763 + ContextType 11764 + >; 11765 + exchangeApiSchema?: Resolver< 11766 + Maybe<GQLResolversTypes['ExchangeApiSchema']>, 11767 + ParentType, 11768 + ContextType, 11769 + RequireFields<GQLQueryExchangeApiSchemaArgs, 'apiName'> 11770 + >; 11771 + exchangeApis?: Resolver< 11772 + ReadonlyArray<GQLResolversTypes['ExchangeApiInfo']>, 11559 11773 ParentType, 11560 11774 ContextType 11561 11775 >; ··· 14212 14426 EnqueueToNcmecAction?: GQLEnqueueToNcmecActionResolvers<ContextType>; 14213 14427 EnumSignalOutputType?: GQLEnumSignalOutputTypeResolvers<ContextType>; 14214 14428 Error?: GQLErrorResolvers<ContextType>; 14429 + ExchangeApiInfo?: GQLExchangeApiInfoResolvers<ContextType>; 14430 + ExchangeApiSchema?: GQLExchangeApiSchemaResolvers<ContextType>; 14431 + ExchangeFieldDescriptor?: GQLExchangeFieldDescriptorResolvers<ContextType>; 14432 + ExchangeInfo?: GQLExchangeInfoResolvers<ContextType>; 14433 + ExchangeSchemaSection?: GQLExchangeSchemaSectionResolvers<ContextType>; 14215 14434 ExecuteActionResponse?: GQLExecuteActionResponseResolvers<ContextType>; 14216 14435 ExecuteBulkActionResponse?: GQLExecuteBulkActionResponseResolvers<ContextType>; 14217 14436 Field?: GQLFieldResolvers<ContextType>; ··· 14252 14471 ItemTypeSchemaVariant?: GQLItemTypeSchemaVariantResolvers; 14253 14472 ItemTypeSchemaVariantInput?: GQLItemTypeSchemaVariantInputResolvers; 14254 14473 ItemWithParents?: GQLItemWithParentsResolvers<ContextType>; 14474 + JSON?: GraphQLScalarType; 14255 14475 JSONObject?: GraphQLScalarType; 14256 14476 JobCreationCount?: GQLJobCreationCountResolvers<ContextType>; 14257 14477 JobCreationFilterBy?: GQLJobCreationFilterByResolvers<ContextType>;
+6
server/graphql/modules/generic.ts
··· 32 32 """ 33 33 scalar JSONObject 34 34 35 + """ 36 + Represents any JSON value (object, array, string, number, boolean, null). 37 + """ 38 + scalar JSON 39 + 35 40 "Information about the current page in a connection." 36 41 type PageInfo { 37 42 "When paginating forwards, are there more items?" ··· 92 97 DateTime: scalars.GraphQLDateTime, 93 98 Date: scalars.GraphQLDate, 94 99 Cursor, 100 + JSON: scalars.GraphQLJSON, 95 101 JSONObject: scalars.GraphQLJSONObject, 96 102 NonEmptyString, 97 103 };
+159
server/graphql/modules/hashBanks/resolvers.test.ts
··· 1 + /* eslint-disable @typescript-eslint/no-explicit-any */ 2 + import { resolvers } from './resolvers.js'; 3 + import type { HashBank } from '../../../services/hmaService/index.js'; 4 + 5 + const MOCK_BANK: HashBank = { 6 + id: 1, 7 + name: 'test bank', 8 + hma_name: 'COOP_ORG1_TEST_BANK', 9 + description: 'desc', 10 + enabled_ratio: 1.0, 11 + org_id: 'org1', 12 + created_at: new Date(), 13 + updated_at: new Date(), 14 + }; 15 + 16 + function makeContext(overrides: Record<string, jest.Mock> = {}) { 17 + return { 18 + getUser: () => ({ orgId: 'org1' }), 19 + services: { 20 + HMAHashBankService: { 21 + createBank: jest.fn().mockResolvedValue(MOCK_BANK), 22 + setExchangeCredentials: jest.fn().mockResolvedValue(undefined), 23 + getExchangeForBank: jest.fn().mockResolvedValue(null), 24 + ...overrides, 25 + }, 26 + }, 27 + }; 28 + } 29 + 30 + describe('hashBanks resolvers', () => { 31 + describe('Mutation.createHashBank', () => { 32 + it('creates a bank without exchange', async () => { 33 + const ctx = makeContext(); 34 + const input = { name: 'test bank', description: 'desc', enabled_ratio: 1.0 }; 35 + 36 + const result = await (resolvers.Mutation as any).createHashBank({}, { input }, ctx); 37 + 38 + expect(result).toHaveProperty('data'); 39 + expect(ctx.services.HMAHashBankService.createBank).toHaveBeenCalledWith( 40 + 'org1', 'test bank', 'desc', 1.0, undefined 41 + ); 42 + expect(ctx.services.HMAHashBankService.setExchangeCredentials).not.toHaveBeenCalled(); 43 + }); 44 + 45 + it('creates a bank with exchange and credentials', async () => { 46 + const ctx = makeContext(); 47 + const input = { 48 + name: 'test bank', 49 + description: 'desc', 50 + enabled_ratio: 1.0, 51 + exchange: { 52 + api_name: 'fb_threatexchange', 53 + config_json: '{"privacy_group":123}', 54 + credentials_json: '{"api_token":"tok"}', 55 + }, 56 + }; 57 + 58 + const result = await (resolvers.Mutation as any).createHashBank({}, { input }, ctx); 59 + 60 + expect(result).toHaveProperty('data'); 61 + expect(ctx.services.HMAHashBankService.createBank).toHaveBeenCalledWith( 62 + 'org1', 'test bank', 'desc', 1.0, 63 + { apiName: 'fb_threatexchange', apiJson: { privacy_group: 123 } } 64 + ); 65 + expect(ctx.services.HMAHashBankService.setExchangeCredentials).toHaveBeenCalledWith( 66 + 'fb_threatexchange', { api_token: 'tok' } 67 + ); 68 + }); 69 + 70 + it('returns success with warning when credentials fail', async () => { 71 + const ctx = makeContext({ 72 + createBank: jest.fn().mockResolvedValue(MOCK_BANK), 73 + setExchangeCredentials: jest.fn().mockRejectedValue(new Error('cred error')), 74 + }); 75 + const input = { 76 + name: 'test bank', 77 + description: 'desc', 78 + enabled_ratio: 1.0, 79 + exchange: { 80 + api_name: 'ncmec', 81 + config_json: '{"environment":"https://test.ncmec.org"}', 82 + credentials_json: '{"user":"u","password":"p"}', 83 + }, 84 + }; 85 + 86 + const result = await (resolvers.Mutation as any).createHashBank({}, { input }, ctx); 87 + 88 + expect(result).toHaveProperty('data'); 89 + expect(result.warning).toContain('credentials could not be set'); 90 + }); 91 + 92 + it('does not set credentials when credentials_json is absent', async () => { 93 + const ctx = makeContext(); 94 + const input = { 95 + name: 'test bank', 96 + description: 'desc', 97 + enabled_ratio: 1.0, 98 + exchange: { 99 + api_name: 'stop_ncii', 100 + config_json: '{}', 101 + }, 102 + }; 103 + 104 + await (resolvers.Mutation as any).createHashBank({}, { input }, ctx); 105 + 106 + expect(ctx.services.HMAHashBankService.setExchangeCredentials).not.toHaveBeenCalled(); 107 + }); 108 + }); 109 + 110 + describe('Mutation.updateExchangeCredentials', () => { 111 + it('calls setExchangeCredentials and returns true', async () => { 112 + const ctx = makeContext(); 113 + 114 + const result = await (resolvers.Mutation as any).updateExchangeCredentials( 115 + {}, 116 + { apiName: 'ncmec', credentialsJson: '{"user":"u","password":"p"}' }, 117 + ctx 118 + ); 119 + 120 + expect(result).toBe(true); 121 + expect(ctx.services.HMAHashBankService.setExchangeCredentials).toHaveBeenCalledWith( 122 + 'ncmec', { user: 'u', password: 'p' } 123 + ); 124 + }); 125 + }); 126 + 127 + describe('HashBank.exchange', () => { 128 + it('resolves exchange info for a bank', async () => { 129 + const exchangeInfo = { 130 + api: 'fb_threatexchange', 131 + enabled: true, 132 + has_auth: true, 133 + last_fetch_succeeded: true, 134 + }; 135 + const ctx = makeContext({ 136 + getExchangeForBank: jest.fn().mockResolvedValue(exchangeInfo), 137 + }); 138 + 139 + const result = await (resolvers as any).HashBank.exchange( 140 + { hma_name: 'COOP_ORG1_TEST_BANK' }, {}, ctx 141 + ); 142 + 143 + expect(result).toEqual(exchangeInfo); 144 + expect(ctx.services.HMAHashBankService.getExchangeForBank).toHaveBeenCalledWith( 145 + 'COOP_ORG1_TEST_BANK' 146 + ); 147 + }); 148 + 149 + it('returns null when no exchange is configured', async () => { 150 + const ctx = makeContext(); 151 + 152 + const result = await (resolvers as any).HashBank.exchange( 153 + { hma_name: 'COOP_ORG1_STANDALONE' }, {}, ctx 154 + ); 155 + 156 + expect(result).toBeNull(); 157 + }); 158 + }); 159 + });
+83 -5
server/graphql/modules/hashBanks/resolvers.ts
··· 4 4 import type { GQLMutationResolvers, GQLQueryResolvers } from '../../generated.js'; 5 5 import { gqlErrorResult, gqlSuccessResult } from '../../utils/gqlResult.js'; 6 6 7 + interface ExchangeConfigInput { 8 + api_name: string; 9 + config_json: string; 10 + credentials_json?: string | null; 11 + } 12 + 7 13 const Query: GQLQueryResolvers<Context> = { 8 14 async hashBanks(_: unknown, __: unknown, context: Context) { 9 15 const user = context.getUser(); ··· 44 50 } 45 51 throw e; 46 52 } 47 - } 53 + }, 54 + 55 + async exchangeApis(_: unknown, __: unknown, context: Context) { 56 + const user = context.getUser(); 57 + if (!user?.orgId) { 58 + throw new AuthenticationError('User required.'); 59 + } 60 + 61 + return context.services.HMAHashBankService.getExchangeApis(); 62 + }, 63 + 64 + async exchangeApiSchema(_: unknown, { apiName }: { apiName: string }, context: Context) { 65 + const user = context.getUser(); 66 + if (!user?.orgId) { 67 + throw new AuthenticationError('User required.'); 68 + } 69 + 70 + return context.services.HMAHashBankService.getExchangeApiSchema(apiName); 71 + }, 48 72 }; 49 73 50 74 const Mutation: GQLMutationResolvers<Context> = { 51 75 async createHashBank( 52 76 _: unknown, 53 - { input }: { input: { name: string; description?: string | null; enabled_ratio: number } }, 77 + { input }: { input: { 78 + name: string; 79 + description?: string | null; 80 + enabled_ratio: number; 81 + exchange?: ExchangeConfigInput | null; 82 + }}, 54 83 context: Context 55 84 ) { 56 85 const user = context.getUser(); ··· 59 88 } 60 89 61 90 try { 91 + const exchangeConfig = input.exchange 92 + ? { 93 + apiName: input.exchange.api_name, 94 + // eslint-disable-next-line no-restricted-syntax 95 + apiJson: JSON.parse(input.exchange.config_json) as Record<string, unknown>, 96 + } 97 + : undefined; 98 + 62 99 const bank = await context.services.HMAHashBankService.createBank( 63 100 user.orgId, 64 101 input.name, 65 102 input.description ?? '', 66 - input.enabled_ratio 103 + input.enabled_ratio, 104 + exchangeConfig 67 105 ); 68 - return gqlSuccessResult({ data: bank }, 'MutateHashBankSuccessResponse'); 106 + 107 + let warning: string | undefined; 108 + if (input.exchange?.credentials_json) { 109 + try { 110 + // eslint-disable-next-line no-restricted-syntax 111 + const credData = JSON.parse(input.exchange.credentials_json) as Record<string, unknown>; 112 + await context.services.HMAHashBankService.setExchangeCredentials( 113 + input.exchange.api_name, 114 + credData 115 + ); 116 + } catch (credError) { 117 + // eslint-disable-next-line no-console 118 + console.error('Failed to set exchange credentials during bank creation:', credError); 119 + warning = 'Bank and exchange were created, but credentials could not be set. You can update them from the bank settings page.'; 120 + } 121 + } 122 + 123 + return gqlSuccessResult({ data: bank, warning }, 'MutateHashBankSuccessResponse'); 69 124 } catch (e) { 70 125 if (isCoopErrorOfType(e, 'MatchingBankNameExistsError')) { 71 126 return gqlErrorResult(e, '/input/name'); ··· 115 170 116 171 await context.services.HMAHashBankService.deleteBank(user.orgId, id); 117 172 return true; 173 + }, 174 + 175 + async updateExchangeCredentials( 176 + _: unknown, 177 + { apiName, credentialsJson }: { apiName: string; credentialsJson: string }, 178 + context: Context 179 + ) { 180 + const user = context.getUser(); 181 + if (!user?.orgId) { 182 + throw new AuthenticationError('User required.'); 183 + } 184 + 185 + // eslint-disable-next-line no-restricted-syntax 186 + const credData = JSON.parse(credentialsJson) as Record<string, unknown>; 187 + await context.services.HMAHashBankService.setExchangeCredentials(apiName, credData); 188 + return true; 118 189 } 119 190 }; 120 191 192 + const HashBank = { 193 + async exchange(parent: { hma_name: string }, _args: unknown, context: Context) { 194 + return context.services.HMAHashBankService.getExchangeForBank(parent.hma_name); 195 + }, 196 + }; 197 + 121 198 export const resolvers = { 122 199 Query, 123 - Mutation 200 + Mutation, 201 + HashBank, 124 202 };
+48
server/graphql/modules/hashBanks/schema.ts
··· 1 1 import { gql } from 'apollo-server-express'; 2 2 3 3 export const typeDefs = gql` 4 + type ExchangeInfo { 5 + api: String! 6 + enabled: Boolean! 7 + has_auth: Boolean! 8 + error: String 9 + last_fetch_succeeded: Boolean 10 + last_fetch_time: String 11 + up_to_date: Boolean 12 + fetched_items: Int 13 + is_fetching: Boolean 14 + } 15 + 4 16 type HashBank { 5 17 id: ID! 6 18 name: String! ··· 8 20 hma_name: String! 9 21 enabled_ratio: Float! 10 22 org_id: String! 23 + exchange: ExchangeInfo 24 + } 25 + 26 + type ExchangeFieldDescriptor { 27 + name: String! 28 + type: String! 29 + required: Boolean! 30 + default: JSON 31 + help: String 32 + choices: [String!] 33 + } 34 + 35 + type ExchangeSchemaSection { 36 + fields: [ExchangeFieldDescriptor!]! 37 + } 38 + 39 + type ExchangeApiSchema { 40 + config_schema: ExchangeSchemaSection! 41 + credentials_schema: ExchangeSchemaSection 42 + } 43 + 44 + type ExchangeApiInfo { 45 + name: String! 46 + supports_auth: Boolean! 47 + has_auth: Boolean! 48 + } 49 + 50 + input ExchangeConfigInput { 51 + api_name: String! 52 + config_json: String! 53 + credentials_json: String 11 54 } 12 55 13 56 input CreateHashBankInput { 14 57 name: String! 15 58 description: String 16 59 enabled_ratio: Float! 60 + exchange: ExchangeConfigInput 17 61 } 18 62 19 63 input UpdateHashBankInput { ··· 25 69 26 70 type MutateHashBankSuccessResponse { 27 71 data: HashBank! 72 + warning: String 28 73 } 29 74 30 75 type MatchingBankNameExistsError implements Error { ··· 44 89 hashBanks: [HashBank!]! 45 90 hashBank(name: String!): HashBank 46 91 hashBankById(id: ID!): HashBank 92 + exchangeApis: [ExchangeApiInfo!]! 93 + exchangeApiSchema(apiName: String!): ExchangeApiSchema 47 94 } 48 95 49 96 type Mutation { 50 97 createHashBank(input: CreateHashBankInput!): MutateHashBankResponse! 51 98 updateHashBank(input: UpdateHashBankInput!): MutateHashBankResponse! 52 99 deleteHashBank(id: ID!): Boolean! 100 + updateExchangeCredentials(apiName: String!, credentialsJson: String!): Boolean! 53 101 } 54 102 `;
+1 -1
server/package.json
··· 6 6 "scripts": { 7 7 "build": "tsc && npm run copy-assets", 8 8 "copy-assets": "copyfiles \"lib/**/*.lua\" transpiled/", 9 - "start": "tsc-watch --onSuccess \"node --require dotenv/config ./transpiled/bin/www.js\"", 9 + "start": "tsc-watch --onSuccess \"node --trace-warnings --require dotenv/config ./transpiled/bin/www.js\"", 10 10 "start:trace": "tsc-watch --onSuccess \"node --trace-warnings --require dotenv/config --require ../nodejs-instrumentation/transpiled/autoinstrumentation.js ./transpiled/bin/www.js\"", 11 11 "test": "npm run test:local", 12 12 "test:local": "NODE_OPTIONS=\"--no-warnings --loader ts-node/esm --require dotenv/config\" jest --watch --detectOpenHandles",
+338
server/services/hmaService/index.test.ts
··· 1 + import { HmaService, type ExchangeInfo } from './index.js'; 2 + import type { HashBank } from './dbTypes.js'; 3 + import { jsonParse } from '../../utils/encoding.js'; 4 + 5 + const MOCK_BANK: HashBank = { 6 + id: 1, 7 + name: 'test bank', 8 + hma_name: 'COOP_ORG1_TEST_BANK', 9 + description: 'desc', 10 + enabled_ratio: 1.0, 11 + org_id: 'org1', 12 + created_at: new Date(), 13 + updated_at: new Date(), 14 + }; 15 + 16 + function makeMockKyselyPg() { 17 + const mockChain = { 18 + values: jest.fn().mockReturnThis(), 19 + returningAll: jest.fn().mockReturnThis(), 20 + executeTakeFirstOrThrow: jest.fn().mockResolvedValue(MOCK_BANK), 21 + selectAll: jest.fn().mockReturnThis(), 22 + where: jest.fn().mockReturnThis(), 23 + executeTakeFirst: jest.fn().mockResolvedValue(MOCK_BANK), 24 + execute: jest.fn().mockResolvedValue([]), 25 + set: jest.fn().mockReturnThis(), 26 + }; 27 + return { 28 + insertInto: jest.fn().mockReturnValue(mockChain), 29 + selectFrom: jest.fn().mockReturnValue(mockChain), 30 + updateTable: jest.fn().mockReturnValue(mockChain), 31 + deleteFrom: jest.fn().mockReturnValue(mockChain), 32 + _chain: mockChain, 33 + } as unknown as ConstructorParameters<typeof HmaService>[1]; 34 + } 35 + 36 + function makeService(fetchHTTP: jest.Mock) { 37 + return new HmaService(fetchHTTP as never, makeMockKyselyPg()); 38 + } 39 + 40 + function ok(body: unknown) { 41 + return { ok: true, status: 200, body, headers: {} }; 42 + } 43 + 44 + function created() { 45 + return { ok: true, status: 201, body: undefined, headers: {} }; 46 + } 47 + 48 + function fail(status: number, body?: unknown) { 49 + return { ok: false, status, body, headers: {} }; 50 + } 51 + 52 + describe('HmaService', () => { 53 + describe('createBank', () => { 54 + it('creates a standalone bank via POST /c/banks when no exchange is provided', async () => { 55 + const fetchHTTP = jest.fn().mockResolvedValue(ok({ name: 'COOP_ORG1_MY_BANK', matching_enabled_ratio: 1.0 })); 56 + const svc = makeService(fetchHTTP); 57 + 58 + const result = await svc.createBank('org1', 'My Bank', 'desc', 1.0); 59 + 60 + expect(result).toMatchObject({ name: 'test bank' }); 61 + expect(fetchHTTP).toHaveBeenCalledTimes(1); 62 + const call = fetchHTTP.mock.calls[0][0]; 63 + expect(call.url).toContain('/c/banks'); 64 + expect(call.method).toBe('post'); 65 + }); 66 + 67 + it('creates a bank via POST /c/exchanges when exchange config is provided', async () => { 68 + const fetchHTTP = jest.fn().mockResolvedValue(created()); 69 + const svc = makeService(fetchHTTP); 70 + 71 + const result = await svc.createBank('org1', 'My Bank', 'desc', 1.0, { 72 + apiName: 'fb_threatexchange', 73 + apiJson: { privacy_group: 123 }, 74 + }); 75 + 76 + expect(result).toMatchObject({ name: 'test bank' }); 77 + const exchangeCall = fetchHTTP.mock.calls[0][0]; 78 + expect(exchangeCall.url).toContain('/c/exchanges'); 79 + expect(exchangeCall.method).toBe('post'); 80 + const body = jsonParse(exchangeCall.body); 81 + expect(body.bank).toBe('COOP_ORG1_MY_BANK'); 82 + expect(body.api).toBe('fb_threatexchange'); 83 + expect(body.api_json).toEqual({ privacy_group: 123 }); 84 + }); 85 + 86 + it('updates enabled_ratio after exchange creation when not 1.0', async () => { 87 + const fetchHTTP = jest.fn().mockResolvedValue(created()); 88 + const svc = makeService(fetchHTTP); 89 + 90 + await svc.createBank('org1', 'My Bank', 'desc', 0.5, { 91 + apiName: 'fb_threatexchange', 92 + apiJson: { privacy_group: 123 }, 93 + }); 94 + 95 + expect(fetchHTTP).toHaveBeenCalledTimes(2); 96 + const ratioCall = fetchHTTP.mock.calls[1][0]; 97 + expect(ratioCall.url).toContain('/c/bank/COOP_ORG1_MY_BANK'); 98 + expect(ratioCall.method).toBe('put'); 99 + }); 100 + 101 + it('throws when HMA returns an error for exchange creation', async () => { 102 + const fetchHTTP = jest.fn().mockResolvedValue(fail(500)); 103 + const svc = makeService(fetchHTTP); 104 + 105 + await expect( 106 + svc.createBank('org1', 'My Bank', 'desc', 1.0, { 107 + apiName: 'fb_threatexchange', 108 + apiJson: { privacy_group: 123 }, 109 + }) 110 + ).rejects.toThrow('Failed to create exchange in HMA'); 111 + }); 112 + 113 + it('throws when HMA returns an error for standalone bank creation', async () => { 114 + const fetchHTTP = jest.fn().mockResolvedValue(fail(409)); 115 + const svc = makeService(fetchHTTP); 116 + 117 + await expect( 118 + svc.createBank('org1', 'My Bank', 'desc', 1.0) 119 + ).rejects.toThrow('Failed to create HMA bank'); 120 + }); 121 + }); 122 + 123 + describe('setExchangeCredentials', () => { 124 + it('sends credentials to the correct endpoint', async () => { 125 + const fetchHTTP = jest.fn().mockResolvedValue(created()); 126 + const svc = makeService(fetchHTTP); 127 + 128 + await svc.setExchangeCredentials('ncmec', { user: 'u', password: 'p' }); 129 + 130 + expect(fetchHTTP).toHaveBeenCalledTimes(1); 131 + const call = fetchHTTP.mock.calls[0][0]; 132 + expect(call.url).toContain('/c/exchanges/api/ncmec'); 133 + expect(call.method).toBe('post'); 134 + const body = jsonParse(call.body); 135 + expect(body.credential_json).toEqual({ user: 'u', password: 'p' }); 136 + }); 137 + 138 + it('throws when HMA returns an error', async () => { 139 + const fetchHTTP = jest.fn().mockResolvedValue(fail(400)); 140 + const svc = makeService(fetchHTTP); 141 + 142 + await expect( 143 + svc.setExchangeCredentials('ncmec', { user: 'u', password: 'p' }) 144 + ).rejects.toThrow("Failed to set exchange credentials for 'ncmec'"); 145 + }); 146 + }); 147 + 148 + describe('getExchangeForBank', () => { 149 + it('returns null when HMA returns 404 (no exchange configured)', async () => { 150 + const fetchHTTP = jest.fn().mockResolvedValue(fail(404)); 151 + const svc = makeService(fetchHTTP); 152 + 153 + const result = await svc.getExchangeForBank('COOP_ORG1_BANK'); 154 + 155 + expect(result).toBeNull(); 156 + }); 157 + 158 + it('returns error info when HMA returns a non-404 error', async () => { 159 + const fetchHTTP = jest.fn().mockResolvedValue(fail(500)); 160 + const svc = makeService(fetchHTTP); 161 + 162 + const result = await svc.getExchangeForBank('COOP_ORG1_BANK'); 163 + 164 + expect(result).not.toBeNull(); 165 + expect(result!.error).toContain('status 500'); 166 + }); 167 + 168 + it('returns error info when HMA is unreachable', async () => { 169 + const fetchHTTP = jest.fn().mockRejectedValue(new Error('ECONNREFUSED')); 170 + const svc = makeService(fetchHTTP); 171 + 172 + const result = await svc.getExchangeForBank('COOP_ORG1_BANK'); 173 + 174 + expect(result).not.toBeNull(); 175 + expect(result!.error).toContain('ECONNREFUSED'); 176 + }); 177 + 178 + it('returns full exchange info with fetch status on success', async () => { 179 + const fetchHTTP = jest.fn() 180 + .mockResolvedValueOnce(ok({ 181 + api: 'fb_threatexchange', 182 + enabled: true, 183 + name: 'COOP_ORG1_BANK', 184 + })) 185 + .mockResolvedValueOnce(ok({ 186 + supports_authentification: true, 187 + has_set_authentification: true, 188 + })) 189 + .mockResolvedValueOnce(ok({ 190 + last_fetch_succeeded: true, 191 + last_fetch_complete_ts: 1700000000, 192 + up_to_date: true, 193 + fetched_items: 42, 194 + running_fetch_start_ts: null, 195 + checkpoint_ts: 1700000000, 196 + })); 197 + const svc = makeService(fetchHTTP); 198 + 199 + const result = await svc.getExchangeForBank('COOP_ORG1_BANK'); 200 + 201 + expect(result).toEqual<ExchangeInfo>({ 202 + api: 'fb_threatexchange', 203 + enabled: true, 204 + has_auth: true, 205 + last_fetch_succeeded: true, 206 + last_fetch_time: new Date(1700000000 * 1000).toISOString(), 207 + up_to_date: true, 208 + fetched_items: 42, 209 + is_fetching: false, 210 + }); 211 + }); 212 + 213 + it('returns fetch failed status', async () => { 214 + const fetchHTTP = jest.fn() 215 + .mockResolvedValueOnce(ok({ 216 + api: 'ncmec', 217 + enabled: true, 218 + name: 'COOP_ORG1_BANK', 219 + })) 220 + .mockResolvedValueOnce(ok({ 221 + supports_authentification: true, 222 + has_set_authentification: false, 223 + })) 224 + .mockResolvedValueOnce(ok({ 225 + last_fetch_succeeded: false, 226 + last_fetch_complete_ts: 1700000000, 227 + up_to_date: false, 228 + fetched_items: 0, 229 + running_fetch_start_ts: null, 230 + checkpoint_ts: null, 231 + })); 232 + const svc = makeService(fetchHTTP); 233 + 234 + const result = await svc.getExchangeForBank('COOP_ORG1_BANK'); 235 + 236 + expect(result!.last_fetch_succeeded).toBe(false); 237 + expect(result!.has_auth).toBe(false); 238 + expect(result!.fetched_items).toBe(0); 239 + }); 240 + 241 + it('detects active fetch in progress', async () => { 242 + const fetchHTTP = jest.fn() 243 + .mockResolvedValueOnce(ok({ 244 + api: 'fb_threatexchange', 245 + enabled: true, 246 + name: 'COOP_ORG1_BANK', 247 + })) 248 + .mockResolvedValueOnce(ok({ 249 + supports_authentification: true, 250 + has_set_authentification: true, 251 + })) 252 + .mockResolvedValueOnce(ok({ 253 + last_fetch_succeeded: true, 254 + last_fetch_complete_ts: 1700000000, 255 + up_to_date: false, 256 + fetched_items: 10, 257 + running_fetch_start_ts: 1700001000, 258 + checkpoint_ts: 1700000000, 259 + })); 260 + const svc = makeService(fetchHTTP); 261 + 262 + const result = await svc.getExchangeForBank('COOP_ORG1_BANK'); 263 + 264 + expect(result!.is_fetching).toBe(true); 265 + }); 266 + 267 + it('gracefully handles status endpoint failure', async () => { 268 + const fetchHTTP = jest.fn() 269 + .mockResolvedValueOnce(ok({ 270 + api: 'fb_threatexchange', 271 + enabled: true, 272 + name: 'COOP_ORG1_BANK', 273 + })) 274 + .mockResolvedValueOnce(ok({ 275 + supports_authentification: true, 276 + has_set_authentification: true, 277 + })) 278 + .mockRejectedValueOnce(new Error('timeout')); 279 + const svc = makeService(fetchHTTP); 280 + 281 + const result = await svc.getExchangeForBank('COOP_ORG1_BANK'); 282 + 283 + expect(result!.api).toBe('fb_threatexchange'); 284 + expect(result!.has_auth).toBe(true); 285 + expect(result!.last_fetch_succeeded).toBeUndefined(); 286 + }); 287 + }); 288 + 289 + describe('getExchangeApiSchema', () => { 290 + it('returns schema from HMA when endpoint is available', async () => { 291 + const hmaSchema = { 292 + config_schema: { 293 + fields: [{ name: 'privacy_group', type: 'number', required: true, default: null, help: 'PG ID', choices: null }], 294 + }, 295 + credentials_schema: { 296 + fields: [{ name: 'api_token', type: 'string', required: true, default: null, help: 'Token', choices: null }], 297 + }, 298 + }; 299 + const fetchHTTP = jest.fn().mockResolvedValue(ok(hmaSchema)); 300 + const svc = makeService(fetchHTTP); 301 + 302 + const result = await svc.getExchangeApiSchema('fb_threatexchange'); 303 + 304 + expect(result.config_schema.fields).toHaveLength(1); 305 + expect(result.config_schema.fields[0].name).toBe('privacy_group'); 306 + }); 307 + 308 + it('falls back to built-in schema when HMA endpoint fails', async () => { 309 + const fetchHTTP = jest.fn().mockResolvedValue(fail(404)); 310 + const svc = makeService(fetchHTTP); 311 + 312 + const result = await svc.getExchangeApiSchema('fb_threatexchange'); 313 + 314 + expect(result.config_schema.fields).toHaveLength(1); 315 + expect(result.config_schema.fields[0].name).toBe('privacy_group'); 316 + }); 317 + 318 + it('falls back to built-in schema on network error', async () => { 319 + const fetchHTTP = jest.fn().mockRejectedValue(new Error('ECONNREFUSED')); 320 + const svc = makeService(fetchHTTP); 321 + 322 + const result = await svc.getExchangeApiSchema('ncmec'); 323 + 324 + expect(result.config_schema.fields.length).toBeGreaterThan(0); 325 + expect(result.credentials_schema).not.toBeNull(); 326 + }); 327 + 328 + it('returns empty schema for unknown exchange type with no fallback', async () => { 329 + const fetchHTTP = jest.fn().mockResolvedValue(fail(404)); 330 + const svc = makeService(fetchHTTP); 331 + 332 + const result = await svc.getExchangeApiSchema('unknown_exchange'); 333 + 334 + expect(result.config_schema.fields).toHaveLength(0); 335 + expect(result.credentials_schema).toBeNull(); 336 + }); 337 + }); 338 + });
+407 -27
server/services/hmaService/index.ts
··· 1 1 /* eslint-disable max-lines */ 2 + import { type JsonValue } from 'type-fest'; 2 3 import { inject } from '../../iocContainer/utils.js'; 3 4 import type { Dependencies } from '../../iocContainer/index.js'; 4 5 import { jsonStringify } from '../../utils/encoding.js'; ··· 29 30 [bankName: string]: BankMatch[] | undefined; 30 31 } 31 32 33 + export interface ExchangeFieldDescriptor { 34 + name: string; 35 + type: string; 36 + required: boolean; 37 + default: JsonValue | null; 38 + help: string; 39 + choices: string[] | null; 40 + } 41 + 42 + export interface ExchangeSchemaSection { 43 + fields: ExchangeFieldDescriptor[]; 44 + } 45 + 46 + export interface ExchangeApiSchema { 47 + config_schema: ExchangeSchemaSection; 48 + credentials_schema: ExchangeSchemaSection | null; 49 + } 50 + 51 + export interface ExchangeApiInfo { 52 + name: string; 53 + supports_auth: boolean; 54 + has_auth: boolean; 55 + } 56 + 57 + export interface ExchangeInfo { 58 + api: string; 59 + enabled: boolean; 60 + has_auth: boolean; 61 + error?: string | null; 62 + last_fetch_succeeded?: boolean | null; 63 + last_fetch_time?: string | null; 64 + up_to_date?: boolean | null; 65 + fetched_items?: number | null; 66 + is_fetching?: boolean | null; 67 + } 68 + 69 + const KNOWN_EXCHANGE_SCHEMAS: Partial<Record<string, ExchangeApiSchema>> = { 70 + fb_threatexchange: { 71 + config_schema: { 72 + fields: [ 73 + { 74 + name: 'privacy_group', 75 + type: 'number', 76 + required: true, 77 + default: null, 78 + help: 'ThreatPrivacyGroup ID for this collaboration', 79 + choices: null, 80 + }, 81 + ], 82 + }, 83 + credentials_schema: { 84 + fields: [ 85 + { 86 + name: 'api_token', 87 + type: 'string', 88 + required: true, 89 + default: null, 90 + help: 'Meta app access token. Create one at https://developers.facebook.com/tools/accesstoken/ for your ThreatExchange app.', 91 + choices: null, 92 + }, 93 + ], 94 + }, 95 + }, 96 + ncmec: { 97 + config_schema: { 98 + fields: [ 99 + { 100 + name: 'environment', 101 + type: 'enum', 102 + required: true, 103 + default: null, 104 + help: 'which database to connect to', 105 + choices: [ 106 + 'https://report.cybertip.org/hashsharing', 107 + 'https://hashsharing.ncmec.org/npo', 108 + 'https://hashsharing.ncmec.org/exploitative', 109 + 'https://exttest.cybertip.org/hashsharing', 110 + 'https://hashsharing-test.ncmec.org/npo', 111 + 'https://hashsharing-test.ncmec.org/exploitative', 112 + ], 113 + }, 114 + { 115 + name: 'only_esp_ids', 116 + type: 'set_of_number', 117 + required: false, 118 + default: null, 119 + help: 'Only take entries from these electronic service provider (ESP) ids', 120 + choices: null, 121 + }, 122 + ], 123 + }, 124 + credentials_schema: { 125 + fields: [ 126 + { 127 + name: 'user', 128 + type: 'string', 129 + required: true, 130 + default: null, 131 + help: 'NCMEC hash sharing API username.', 132 + choices: null, 133 + }, 134 + { 135 + name: 'password', 136 + type: 'string', 137 + required: true, 138 + default: null, 139 + help: 'NCMEC hash sharing API password.', 140 + choices: null, 141 + }, 142 + ], 143 + }, 144 + }, 145 + stop_ncii: { 146 + config_schema: { fields: [] }, 147 + credentials_schema: { 148 + fields: [ 149 + { 150 + name: 'fetch_function_key', 151 + type: 'string', 152 + required: true, 153 + default: null, 154 + help: 'API key for the Fetch Hashes endpoint. Used when downloading hashes from StopNCII.', 155 + choices: null, 156 + }, 157 + { 158 + name: 'subscription_key', 159 + type: 'string', 160 + required: true, 161 + default: null, 162 + help: 'Azure API Management subscription key. Sent as Ocp-Apim-Subscription-Key on all requests.', 163 + choices: null, 164 + }, 165 + { 166 + name: 'base_url_override', 167 + type: 'string', 168 + required: false, 169 + default: null, 170 + help: 'Optional. Override the API base URL (e.g. for testing). Leave blank to use https://api.stopncii.org/v1', 171 + choices: null, 172 + }, 173 + ], 174 + }, 175 + }, 176 + }; 177 + 32 178 export class HmaService { 33 179 private readonly hmaServiceUrl: string; 34 180 private readonly hashBankService: HashBankService; ··· 52 198 return `COOP_${orgId.toUpperCase()}_${normalizedName}`; 53 199 } 54 200 55 - async createBank(orgId: string, name: string, description: string, enabled_ratio: number): Promise<HashBank> { 56 - // Create HMA bank name with org prefix to avoid collisions 201 + async createBank( 202 + orgId: string, 203 + name: string, 204 + description: string, 205 + enabled_ratio: number, 206 + exchange?: { apiName: string; apiJson: Record<string, unknown> } 207 + ): Promise<HashBank> { 57 208 const hmaName = this.getHmaName(orgId, name); 58 209 59 - const requestBody = { 60 - name: hmaName, 61 - enabled_ratio: enabled_ratio.toString() 62 - }; 210 + if (exchange) { 211 + // POST /c/exchanges creates both the exchange and the bank in HMA 212 + const requestBody = { 213 + bank: hmaName, 214 + api: exchange.apiName, 215 + api_json: exchange.apiJson, 216 + }; 63 217 64 - // Create bank in HMA service 65 - const response = await this.fetchHTTP({ 66 - url: `${this.hmaServiceUrl}/c/banks`, 67 - method: "post", 68 - body: jsonStringify(requestBody), 69 - headers: { 70 - 'Content-Type': 'application/json', 71 - }, 72 - handleResponseBody: 'as-json', 73 - }); 218 + const response = await this.fetchHTTP({ 219 + url: `${this.hmaServiceUrl}/c/exchanges`, 220 + method: 'post', 221 + body: jsonStringify(requestBody), 222 + headers: { 'Content-Type': 'application/json' }, 223 + handleResponseBody: 'discard', 224 + }); 74 225 75 - if (!response.ok) { 76 - const errorDetails = { 77 - status: response.status, 78 - responseBody: response.body, 79 - requestBody, 80 - url: `${this.hmaServiceUrl}/c/banks`, 81 - headers: response.headers 226 + if (!response.ok) { 227 + throw new Error( 228 + `Failed to create exchange in HMA: status=${response.status}` 229 + ); 230 + } 231 + 232 + if (enabled_ratio !== 1.0) { 233 + await this.fetchHTTP({ 234 + url: `${this.hmaServiceUrl}/c/bank/${hmaName}`, 235 + method: 'put', 236 + body: jsonStringify({ enabled_ratio }), 237 + headers: { 'Content-Type': 'application/json' }, 238 + handleResponseBody: 'discard', 239 + }); 240 + } 241 + } else { 242 + const requestBody = { 243 + name: hmaName, 244 + enabled_ratio: enabled_ratio.toString(), 82 245 }; 83 - throw new Error(`Failed to create HMA bank: ${jsonStringify(errorDetails)}`); 246 + 247 + const response = await this.fetchHTTP({ 248 + url: `${this.hmaServiceUrl}/c/banks`, 249 + method: 'post', 250 + body: jsonStringify(requestBody), 251 + headers: { 'Content-Type': 'application/json' }, 252 + handleResponseBody: 'as-json', 253 + }); 254 + 255 + if (!response.ok) { 256 + const errorDetails = { 257 + status: response.status, 258 + responseBody: response.body, 259 + requestBody, 260 + url: `${this.hmaServiceUrl}/c/banks`, 261 + headers: response.headers, 262 + }; 263 + throw new Error(`Failed to create HMA bank: ${jsonStringify(errorDetails)}`); 264 + } 84 265 } 85 266 86 267 try { 87 - // Create local copy of the bank 88 268 const bank = await this.hashBankService.create({ 89 269 name, 90 270 hma_name: hmaName, ··· 95 275 96 276 return bank; 97 277 } catch (error) { 98 - // If we fail to create the local copy, we should try to clean up the HMA bank 99 278 try { 100 279 await this.fetchHTTP({ 101 280 url: `${this.hmaServiceUrl}/c/bank/${hmaName}`, 102 - method: "delete", 281 + method: 'delete', 103 282 handleResponseBody: 'discard', 104 283 }); 105 284 } catch (cleanupError) { ··· 352 531 } catch (error) { 353 532 return []; 354 533 } 534 + } 535 + 536 + async getExchangeApis(): Promise<ExchangeApiInfo[]> { 537 + const apisResponse = await this.fetchHTTP({ 538 + url: `${this.hmaServiceUrl}/c/exchanges/apis`, 539 + method: 'get', 540 + handleResponseBody: 'as-json', 541 + }); 542 + 543 + if (!apisResponse.ok) { 544 + throw new Error(`Failed to fetch exchange APIs: ${apisResponse.status}`); 545 + } 546 + 547 + const apiNames = apisResponse.body as unknown as string[]; 548 + 549 + const infos = await Promise.all( 550 + apiNames.map(async (name) => { 551 + try { 552 + const configResponse = await this.fetchHTTP({ 553 + url: `${this.hmaServiceUrl}/c/exchanges/api/${encodeURIComponent(name)}`, 554 + method: 'get', 555 + handleResponseBody: 'as-json', 556 + }); 557 + 558 + if (configResponse.ok) { 559 + const body = configResponse.body as unknown as { 560 + supports_authentification: boolean; 561 + has_set_authentification: boolean; 562 + }; 563 + return { 564 + name, 565 + supports_auth: body.supports_authentification, 566 + has_auth: body.has_set_authentification, 567 + }; 568 + } 569 + } catch { 570 + // fall through to default 571 + } 572 + return { name, supports_auth: false, has_auth: false }; 573 + }) 574 + ); 575 + return infos; 576 + } 577 + 578 + async getExchangeApiSchema(apiName: string): Promise<ExchangeApiSchema> { 579 + try { 580 + const response = await this.fetchHTTP({ 581 + url: `${this.hmaServiceUrl}/c/exchanges/api/${encodeURIComponent(apiName)}/schema`, 582 + method: 'get', 583 + handleResponseBody: 'as-json', 584 + }); 585 + 586 + if (response.ok) { 587 + return response.body as unknown as ExchangeApiSchema; 588 + } 589 + } catch { 590 + // Fall through to built-in schemas 591 + } 592 + 593 + const fallback = KNOWN_EXCHANGE_SCHEMAS[apiName]; 594 + if (fallback) { 595 + return fallback; 596 + } 597 + 598 + return { config_schema: { fields: [] }, credentials_schema: null }; 599 + } 600 + 601 + async createExchange( 602 + bankName: string, 603 + apiType: string, 604 + apiJson: Record<string, unknown> 605 + ): Promise<void> { 606 + const requestBody = { 607 + bank: bankName, 608 + api: apiType, 609 + api_json: apiJson, 610 + }; 611 + 612 + const response = await this.fetchHTTP({ 613 + url: `${this.hmaServiceUrl}/c/exchanges`, 614 + method: 'post', 615 + body: jsonStringify(requestBody), 616 + headers: { 'Content-Type': 'application/json' }, 617 + handleResponseBody: 'as-json', 618 + }); 619 + 620 + if (!response.ok) { 621 + const errorDetails = { 622 + status: response.status, 623 + responseBody: response.body, 624 + requestBody, 625 + url: `${this.hmaServiceUrl}/c/exchanges`, 626 + }; 627 + throw new Error(`Failed to create exchange: ${jsonStringify(errorDetails)}`); 628 + } 629 + } 630 + 631 + async setExchangeCredentials( 632 + apiName: string, 633 + credentialJson: Record<string, unknown> 634 + ): Promise<void> { 635 + const requestBody = { credential_json: credentialJson }; 636 + 637 + const response = await this.fetchHTTP({ 638 + url: `${this.hmaServiceUrl}/c/exchanges/api/${encodeURIComponent(apiName)}`, 639 + method: 'post', 640 + body: jsonStringify(requestBody), 641 + headers: { 'Content-Type': 'application/json' }, 642 + handleResponseBody: 'discard', 643 + }); 644 + 645 + if (!response.ok) { 646 + throw new Error( 647 + `Failed to set exchange credentials for '${apiName}': status=${response.status}` 648 + ); 649 + } 650 + } 651 + 652 + async getExchangeForBank(hmaName: string): Promise<ExchangeInfo | null> { 653 + let response; 654 + try { 655 + response = await this.fetchHTTP({ 656 + url: `${this.hmaServiceUrl}/c/exchange/${encodeURIComponent(hmaName)}`, 657 + method: 'get', 658 + handleResponseBody: 'as-json', 659 + }); 660 + } catch (err) { 661 + return { 662 + api: '', 663 + enabled: false, 664 + has_auth: false, 665 + error: `Unable to reach HMA service: ${err instanceof Error ? err.message : String(err)}`, 666 + }; 667 + } 668 + 669 + if (response.status === 404) { 670 + return null; 671 + } 672 + 673 + if (!response.ok) { 674 + return { 675 + api: '', 676 + enabled: false, 677 + has_auth: false, 678 + error: `Failed to fetch exchange info from HMA (status ${response.status})`, 679 + }; 680 + } 681 + 682 + const body = response.body as unknown as Record<string, unknown>; 683 + const apiName = String(body.api ?? ''); 684 + 685 + let hasAuth = false; 686 + try { 687 + const apiResponse = await this.fetchHTTP({ 688 + url: `${this.hmaServiceUrl}/c/exchanges/api/${encodeURIComponent(apiName)}`, 689 + method: 'get', 690 + handleResponseBody: 'as-json', 691 + }); 692 + if (apiResponse.ok) { 693 + const apiBody = apiResponse.body as unknown as { 694 + has_set_authentification: boolean; 695 + }; 696 + hasAuth = apiBody.has_set_authentification; 697 + } 698 + } catch { 699 + // Non-critical: can't determine auth status 700 + } 701 + 702 + const info: ExchangeInfo = { 703 + api: apiName, 704 + enabled: Boolean(body.enabled), 705 + has_auth: hasAuth, 706 + }; 707 + 708 + try { 709 + const statusResponse = await this.fetchHTTP({ 710 + url: `${this.hmaServiceUrl}/c/exchange/${encodeURIComponent(hmaName)}/status`, 711 + method: 'get', 712 + handleResponseBody: 'as-json', 713 + }); 714 + if (statusResponse.ok) { 715 + const status = statusResponse.body as unknown as { 716 + last_fetch_succeeded: boolean; 717 + last_fetch_complete_ts: number | null; 718 + up_to_date: boolean; 719 + fetched_items: number; 720 + running_fetch_start_ts: number | null; 721 + }; 722 + info.last_fetch_succeeded = status.last_fetch_succeeded; 723 + info.up_to_date = status.up_to_date; 724 + info.fetched_items = status.fetched_items; 725 + info.is_fetching = status.running_fetch_start_ts != null; 726 + if (status.last_fetch_complete_ts != null) { 727 + info.last_fetch_time = new Date(status.last_fetch_complete_ts * 1000).toISOString(); 728 + } 729 + } 730 + } catch { 731 + // Non-critical: can't determine fetch status 732 + } 733 + 734 + return info; 355 735 } 356 736 357 737 async hashContentFromUrl(url: string): Promise<Record<string, string>> {